openclacky 1.5.13 → 1.5.14

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -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 +38 -97
  6. data/lib/clacky/agent.rb +281 -276
  7. data/lib/clacky/agent_config.rb +1 -1
  8. data/lib/clacky/billing/billing_store.rb +2 -2
  9. data/lib/clacky/billing/platform_billing.rb +6 -0
  10. data/lib/clacky/brand_config.rb +13 -2
  11. data/lib/clacky/cli.rb +27 -0
  12. data/lib/clacky/client.rb +2 -2
  13. data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
  14. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +91 -5
  15. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
  16. data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
  17. data/lib/clacky/default_extensions/preview/ext.yml +20 -0
  18. data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
  19. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
  20. data/lib/clacky/extension/api_extension.rb +1 -1
  21. data/lib/clacky/extension/verifier.rb +1 -1
  22. data/lib/clacky/media/openai_compat.rb +70 -30
  23. data/lib/clacky/message_format/bedrock.rb +6 -1
  24. data/lib/clacky/prompts/base.md +1 -1
  25. data/lib/clacky/providers.rb +38 -3
  26. data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
  27. data/lib/clacky/search_config.rb +3 -3
  28. data/lib/clacky/server/channel/channel_manager.rb +8 -5
  29. data/lib/clacky/server/git_panel.rb +10 -2
  30. data/lib/clacky/server/http_server.rb +161 -14
  31. data/lib/clacky/server/preview.rb +351 -0
  32. data/lib/clacky/server/web_ui_controller.rb +1 -1
  33. data/lib/clacky/skill.rb +48 -0
  34. data/lib/clacky/tools/browser.rb +176 -19
  35. data/lib/clacky/tools/web_search.rb +79 -8
  36. data/lib/clacky/ui2/components/command_suggestions.rb +2 -1
  37. data/lib/clacky/ui2/components/input_area.rb +22 -2
  38. data/lib/clacky/ui2/components/modal_component.rb +35 -6
  39. data/lib/clacky/ui2/ui_controller.rb +44 -7
  40. data/lib/clacky/utils/file_processor.rb +19 -7
  41. data/lib/clacky/utils/mac_app_detector.rb +186 -0
  42. data/lib/clacky/utils/model_pricing.rb +114 -66
  43. data/lib/clacky/utils/windows_app_detector.rb +334 -0
  44. data/lib/clacky/version.rb +1 -1
  45. data/lib/clacky/web/app.css +596 -130
  46. data/lib/clacky/web/components/chat-navigator.js +489 -202
  47. data/lib/clacky/web/components/code-editor.js +191 -5
  48. data/lib/clacky/web/components/model-picker.js +2 -1
  49. data/lib/clacky/web/components/quote-select.js +235 -0
  50. data/lib/clacky/web/core/aside.js +157 -8
  51. data/lib/clacky/web/core/ext.js +16 -0
  52. data/lib/clacky/web/features/billing/view.js +75 -21
  53. data/lib/clacky/web/features/skills/store.js +18 -1
  54. data/lib/clacky/web/features/skills/view.js +128 -7
  55. data/lib/clacky/web/features/workspace/store.js +80 -7
  56. data/lib/clacky/web/features/workspace/view.js +392 -40
  57. data/lib/clacky/web/i18n.js +90 -5
  58. data/lib/clacky/web/index.html +15 -6
  59. data/lib/clacky/web/sessions.js +193 -38
  60. data/lib/clacky/web/utils.js +34 -0
  61. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
  62. data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
  63. data/lib/clacky/web/vendor/codemirror/package.json +29 -0
  64. data/lib/clacky/web/ws-dispatcher.js +20 -0
  65. data/lib/clacky.rb +2 -0
  66. metadata +11 -1
@@ -0,0 +1,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "base64"
5
+ require "digest"
6
+ require "json"
7
+ require_relative "environment_detector"
8
+ require_relative "encoding"
9
+
10
+ module Clacky
11
+ module Utils
12
+ # Detects installed Windows applications able to open a given file type.
13
+ #
14
+ # Windows file associations live in the registry (HKEY_CLASSES_ROOT / the
15
+ # per-user FileExts tree) rather than in app bundles, so this module shells
16
+ # out to PowerShell to read them. The result set mirrors what the Explorer
17
+ # "Open with" dialog offers: the user's default handler, the ProgIDs that
18
+ # declared support for the extension, and the user's recent "open with"
19
+ # choices. Desktop apps resolve to their .exe; packaged (UWP/Store) apps
20
+ # resolve to their AppUserModelID and are launched via shell:appsFolder.
21
+ # Non-WSL hosts always answer with an empty list.
22
+ module WindowsAppDetector
23
+ # `$Ext` is injected as a single-quoted literal after sanitisation, so it
24
+ # is safe to interpolate. Writes UTF-8 so Chinese app names survive the
25
+ # pipe (PowerShell 5.1 would otherwise emit the OEM codepage).
26
+ DETECT_SCRIPT = <<~'PWSH'
27
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
28
+ $ErrorActionPreference = "SilentlyContinue"
29
+ $Ext = '__CLACKY_EXT__'
30
+
31
+ function Parse-ExePath([string]$cmd) {
32
+ if ([string]::IsNullOrWhiteSpace($cmd)) { return $null }
33
+ $cmd = $cmd.Trim()
34
+ if ($cmd.Length -eq 0) { return $null }
35
+ $exe = $null
36
+ if ($cmd[0] -eq '"') {
37
+ $end = $cmd.IndexOf('"', 1)
38
+ if ($end -gt 1) { $exe = $cmd.Substring(1, $end - 1) }
39
+ } else {
40
+ $idx = $cmd.IndexOf(' ')
41
+ if ($idx -lt 0) { $idx = $cmd.IndexOf("`t") }
42
+ if ($idx -gt 0) { $exe = $cmd.Substring(0, $idx) } else { $exe = $cmd }
43
+ }
44
+ if ($exe) { $exe = [Environment]::ExpandEnvironmentVariables($exe) }
45
+ return $exe
46
+ }
47
+
48
+ function Get-DefaultValue([string]$keyPath) {
49
+ $p = Get-Item -Path $keyPath -ErrorAction SilentlyContinue
50
+ if ($p) { return $p.GetValue("") }
51
+ return $null
52
+ }
53
+
54
+ $nameMap = @{}
55
+ Get-StartApps -ErrorAction SilentlyContinue | ForEach-Object {
56
+ if ($_.AppID) { $nameMap[$_.AppID] = $_.Name }
57
+ }
58
+
59
+ $ext = $Ext.TrimStart('.').ToLower()
60
+ $script:apps = @()
61
+ $script:defaultApp = $null
62
+ $script:seen = @{}
63
+
64
+ function Add-App([string]$launch, [string]$name, [bool]$isDefault) {
65
+ if ([string]::IsNullOrWhiteSpace($launch)) { return }
66
+ # A bare .exe name (no directory) is a stale or PATH-only reference.
67
+ # Resolve it against PATH / App Paths so both launching and icon
68
+ # extraction work; skip the entry when it can't be resolved.
69
+ if ($launch -match '(?i)\.exe$' -and $launch -notmatch '[\\/]') {
70
+ $resolved = (Get-Command $launch -ErrorAction SilentlyContinue | Select-Object -First 1).Source
71
+ if (-not $resolved) {
72
+ $resolved = Get-DefaultValue "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\$launch"
73
+ }
74
+ if ([string]::IsNullOrWhiteSpace($resolved) -or -not (Test-Path $resolved)) { return }
75
+ $launch = $resolved
76
+ }
77
+ $key = $launch.ToLower()
78
+ if ($script:seen.ContainsKey($key)) { return }
79
+ $script:seen[$key] = $true
80
+ if ([string]::IsNullOrWhiteSpace($name)) {
81
+ if ($script:nameMap.ContainsKey($launch)) {
82
+ $name = $script:nameMap[$launch]
83
+ } elseif ($launch -match '\.exe$') {
84
+ $name = [System.IO.Path]::GetFileNameWithoutExtension($launch)
85
+ } else {
86
+ $name = ($launch -split '!')[0]
87
+ }
88
+ }
89
+ $entry = @{ name = $name; path = $launch }
90
+ $script:apps += $entry
91
+ if ($isDefault -and -not $script:defaultApp) { $script:defaultApp = $entry }
92
+ }
93
+
94
+ function Add-ProgId([string]$progid, [bool]$isDefault) {
95
+ if ([string]::IsNullOrWhiteSpace($progid)) { return }
96
+ $cmdKey = "Registry::HKEY_CLASSES_ROOT\$progid\shell\open\command"
97
+ $cmdItem = Get-Item -Path $cmdKey -ErrorAction SilentlyContinue
98
+ $exe = if ($cmdItem) { Parse-ExePath ([string]$cmdItem.GetValue("")) } else { $null }
99
+ $name = Get-DefaultValue "Registry::HKEY_CLASSES_ROOT\$progid"
100
+
101
+ # Desktop app: a real exe outside the packaged-apps directory.
102
+ if ($exe -and $exe -notmatch '\\WindowsApps\\') {
103
+ Add-App $exe ([string]$name) $isDefault
104
+ return
105
+ }
106
+ # Packaged (UWP) app: launch via its AppUserModelID; the packaged
107
+ # exe under WindowsApps cannot be launched directly.
108
+ $appItem = Get-Item "Registry::HKEY_CLASSES_ROOT\$progid\Application" -ErrorAction SilentlyContinue
109
+ $aumid = if ($appItem) { [string]$appItem.GetValue("AppUserModelID") } else { $null }
110
+ if ($aumid) {
111
+ Add-App $aumid ([string]$name) $isDefault
112
+ }
113
+ }
114
+
115
+ $ucItem = Get-Item "Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.$ext\UserChoice" -ErrorAction SilentlyContinue
116
+ $userChoice = if ($ucItem) { $ucItem.GetValue("ProgId") } else { $null }
117
+ if ($userChoice) { Add-ProgId ([string]$userChoice) $true }
118
+
119
+ $defaultProg = Get-DefaultValue "Registry::HKEY_CLASSES_ROOT\.$ext"
120
+ if ($defaultProg -and $defaultProg -ne $userChoice) { Add-ProgId ([string]$defaultProg) $true }
121
+
122
+ $hkcr = Get-Item "Registry::HKEY_CLASSES_ROOT\.$ext\OpenWithProgids" -ErrorAction SilentlyContinue
123
+ if ($hkcr) { foreach ($p in $hkcr.Property) { Add-ProgId ([string]$p) $false } }
124
+
125
+ $hkcu = Get-Item "Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.$ext\OpenWithProgids" -ErrorAction SilentlyContinue
126
+ if ($hkcu) { foreach ($p in $hkcu.Property) { Add-ProgId ([string]$p) $false } }
127
+
128
+ $owList = Get-Item "Registry::HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.$ext\OpenWithList" -ErrorAction SilentlyContinue
129
+ if ($owList) {
130
+ foreach ($p in $owList.Property) {
131
+ if ($p -eq 'MRUList') { continue }
132
+ $exeName = [string]$owList.GetValue($p)
133
+ if ([string]::IsNullOrWhiteSpace($exeName)) { continue }
134
+ if ($exeName -match '!') {
135
+ Add-App $exeName $null $false
136
+ } else {
137
+ $cmd = Get-DefaultValue "Registry::HKEY_CLASSES_ROOT\Applications\$exeName\shell\open\command"
138
+ $exe = Parse-ExePath ([string]$cmd)
139
+ if (-not $exe) { $exe = $exeName }
140
+ $name = Get-DefaultValue "Registry::HKEY_CLASSES_ROOT\Applications\$exeName"
141
+ Add-App $exe ([string]$name) $false
142
+ }
143
+ }
144
+ }
145
+
146
+ # No explicit default handler: fall back to the first candidate so the
147
+ # "Open" button still shows a real app logo instead of a generic icon.
148
+ if (-not $script:defaultApp -and $script:apps.Count -gt 0) {
149
+ $script:defaultApp = $script:apps[0]
150
+ }
151
+
152
+ @{ apps = $script:apps; default = $script:defaultApp } | ConvertTo-Json -Depth 4
153
+ PWSH
154
+
155
+ # Extracts the associated icon of an app as base64 PNG on stdout. Accepts
156
+ # either a desktop .exe path or a packaged (UWP) AUMID; an AUMID is
157
+ # resolved to the package's executable via its AppxManifest before the
158
+ # icon is read. `$Target` is injected as a single-quoted literal (embedded
159
+ # single quotes are doubled) so a value cannot break out of the script.
160
+ ICON_SCRIPT = <<~'PWSH'
161
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
162
+ $ErrorActionPreference = "SilentlyContinue"
163
+ $Target = '__CLACKY_TARGET__'
164
+
165
+ $exe = $null
166
+ if ($Target -match '\.exe$') {
167
+ $exe = $Target
168
+ } else {
169
+ $parts = $Target -split '!', 2
170
+ $family = $parts[0]
171
+ $appId = if ($parts.Count -gt 1) { $parts[1] } else { $null }
172
+ $pkg = Get-AppxPackage | Where-Object { $_.PackageFamilyName -eq $family } | Select-Object -First 1
173
+ if ($pkg) {
174
+ $manifest = Join-Path $pkg.InstallLocation 'AppxManifest.xml'
175
+ [xml]$xml = Get-Content $manifest
176
+ if ($xml -and $xml.Package -and $xml.Package.Applications) {
177
+ $app = $null
178
+ foreach ($a in $xml.Package.Applications.Application) {
179
+ if ($appId -and $a.Id -eq $appId) { $app = $a; break }
180
+ }
181
+ if (-not $app) { $app = $xml.Package.Applications.Application | Select-Object -First 1 }
182
+ if ($app -and $app.Executable) { $exe = Join-Path $pkg.InstallLocation $app.Executable }
183
+ }
184
+ }
185
+ }
186
+ if (-not $exe -or -not (Test-Path $exe)) { exit 1 }
187
+
188
+ try {
189
+ Add-Type -AssemblyName System.Drawing
190
+ $icon = [System.Drawing.Icon]::ExtractAssociatedIcon($exe)
191
+ if (-not $icon) { exit 1 }
192
+ $bmp = $icon.ToBitmap()
193
+ $ms = New-Object System.IO.MemoryStream
194
+ $bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
195
+ [Convert]::ToBase64String($ms.ToArray())
196
+ } catch {
197
+ exit 1
198
+ }
199
+ PWSH
200
+
201
+ DETECT_MUTEX = Mutex.new
202
+
203
+ # @param ext [String] file extension, with or without leading dot
204
+ # @return [Array<Hash>] [{ "name" => ..., "path" => exe path or AUMID }]
205
+ def self.apps_for_ext(ext)
206
+ return [] unless wsl?
207
+
208
+ ext = normalize_ext(ext)
209
+ return [] if ext.empty?
210
+
211
+ data = detect_for_ext(ext)
212
+ data ? data["apps"] : []
213
+ end
214
+
215
+ # Return the application Windows would use to open `path`.
216
+ # @param path [String] absolute file path
217
+ # @return [Hash, nil] { "name" => ..., "path" => exe path or AUMID }
218
+ def self.default_app_for(path)
219
+ return nil unless wsl?
220
+
221
+ ext = normalize_ext(File.extname(path))
222
+ return nil if ext.empty?
223
+
224
+ data = detect_for_ext(ext)
225
+ data ? data["default"] : nil
226
+ end
227
+
228
+ # Open a file with a specific installed application.
229
+ # @param path [String] absolute Linux-side file path
230
+ # @param app [String] app name or path from /api/file/apps
231
+ # @return [Boolean, nil] system() result, nil when the app is unknown
232
+ def self.open_with(path, app)
233
+ return nil unless wsl?
234
+
235
+ app = app.to_s
236
+ return nil if app.empty?
237
+
238
+ target = resolve_target(path, app)
239
+ return nil unless target
240
+
241
+ if aumid?(target)
242
+ # explorer.exe hands the launch off to the shell and always exits
243
+ # non-zero, so the exit status cannot signal success — fire-and-forget.
244
+ system("explorer.exe", "shell:appsFolder\\#{target}")
245
+ true
246
+ else
247
+ win_path = Utils::EnvironmentDetector.linux_to_win_path(path)
248
+ system("cmd.exe", "/c", "start", "", target, win_path)
249
+ end
250
+ end
251
+
252
+ # Convert an app's icon to a cached PNG. Accepts a desktop .exe path or a
253
+ # packaged (UWP) AUMID; the AUMID is resolved to the package exe inside
254
+ # the PowerShell script. Answers nil when no icon can be extracted.
255
+ # @param target [String] Windows .exe path or AppUserModelID
256
+ # @return [String, nil] path to the cached PNG file
257
+ def self.icon_png(target)
258
+ return nil unless wsl?
259
+
260
+ target = target.to_s
261
+ return nil if target.empty?
262
+
263
+ cache_dir = File.join(Dir.home, ".clacky", "cache", "app-icons")
264
+ cache = File.join(cache_dir, Digest::MD5.hexdigest(target) + ".png")
265
+ return cache if File.exist?(cache)
266
+
267
+ out, _err, status = run_encoded(ICON_SCRIPT.sub("__CLACKY_TARGET__", target.gsub("'", "''")))
268
+ return nil unless status.success?
269
+ b64 = out.strip
270
+ return nil if b64.empty?
271
+
272
+ require "fileutils"
273
+ FileUtils.mkdir_p(cache_dir)
274
+ File.binwrite(cache, Base64.decode64(b64))
275
+ File.exist?(cache) ? cache : nil
276
+ end
277
+
278
+ def self.wsl?
279
+ Utils::EnvironmentDetector.os_type == :wsl
280
+ end
281
+
282
+ # Strips to [a-z0-9] so the value can never break out of the
283
+ # single-quoted `$Ext` literal inside DETECT_SCRIPT.
284
+ def self.normalize_ext(ext)
285
+ ext.to_s.downcase.sub(/\A\./, "").gsub(/[^a-z0-9]/, "")
286
+ end
287
+
288
+ def self.detect_for_ext(ext)
289
+ @detect_cache ||= {}
290
+ return @detect_cache[ext] if @detect_cache.key?(ext)
291
+
292
+ # powershell.exe takes 1-3s to spawn on WSL; the mutex keeps concurrent
293
+ # /api/file/apps + /api/file/default-app requests from launching a
294
+ # duplicate process for the same extension.
295
+ DETECT_MUTEX.synchronize do
296
+ return @detect_cache[ext] if @detect_cache.key?(ext)
297
+
298
+ out, _err, status = run_encoded(DETECT_SCRIPT.sub("__CLACKY_EXT__", ext))
299
+ parsed = begin
300
+ JSON.parse(out.sub(/\A\xEF\xBB\xBF/, ""))
301
+ rescue StandardError
302
+ nil
303
+ end
304
+ @detect_cache[ext] = status.success? && parsed.is_a?(Hash) ? parsed : nil
305
+ end
306
+ end
307
+
308
+ # The app must match an entry returned by /api/file/apps for this file
309
+ # type, mirroring MacAppDetector's all_apps whitelist — a caller
310
+ # supplied exe path or AUMID is never trusted directly.
311
+ def self.resolve_target(path, app)
312
+ ext = normalize_ext(File.extname(path))
313
+ data = detect_for_ext(ext)
314
+ return nil unless data
315
+
316
+ match = data["apps"].find { |a| a["name"] == app || a["path"] == app }
317
+ match && match["path"]
318
+ end
319
+
320
+ def self.aumid?(target)
321
+ target.to_s.include?("!") && target.to_s !~ /\.exe\z/i
322
+ end
323
+
324
+ def self.run_encoded(script)
325
+ encoded = Base64.strict_encode64(script.encode("UTF-16LE"))
326
+ out, err, status = Open3.capture3("powershell.exe", "-NoProfile", "-EncodedCommand", encoded)
327
+ # The script forces UTF-8 output, but capture3 tags the bytes with the
328
+ # host locale (US-ASCII under LANG=C), which makes JSON.parse choke on
329
+ # non-ASCII app names — retag before the caller parses.
330
+ [Clacky::Utils::Encoding.cmd_to_utf8(out, source_encoding: "UTF-8"), err, status]
331
+ end
332
+ end
333
+ end
334
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.5.13"
4
+ VERSION = "1.5.14"
5
5
  end