openclacky 0.9.16 → 0.9.17

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: 5e4f19a4e5cd22ab1ecc81bfb9c62db1b458a9823699ce853d6094a24b4d3496
4
- data.tar.gz: b0916deb0e5cce5d68f2454ddf35e267409869c19c26e7c203adfd8215aeabe4
3
+ metadata.gz: 1dd996048847d73d0a71f8d4833f338e4c5ae2a58c6228122b817039f2d9fda4
4
+ data.tar.gz: 2003df86f0c4446d6f3d6b7d0c14eef19cc05246735acdd1f79910a0cfb0a324
5
5
  SHA512:
6
- metadata.gz: fe1aeaeac77dddfbb06bfc702c4d09adbffb338b0815481902b3490ab8610448f38ec0921b8e7a6885154b722a89528f911995bb473025339890554399611094
7
- data.tar.gz: ca2505ac215e2cf9430b2b5c7e9629f581108daa6798d6a379e61e8446c199def9537813218b835304c3bf412d8df452767e78fff1a39a05b7d125c93ec0a8f3
6
+ metadata.gz: ab9f7d2b510bdf5dc4736723dbfa602968a584f2d097d4747af4f5c74247f494e66d32a0420c7f7aa4b5e87662d5662671ab3c56e12bdb627bd7b4766504fda9
7
+ data.tar.gz: 4e5543ac5c01d46b5be821769c1505de6797746991fa9605788a1036ed334fa03d6a3c2d7546706dcd0bde5595d94812196acbd51df8e56008c7ba493c1058ec
data/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.17] - 2026-03-27
11
+
12
+ ### Added
13
+ - **Browser screenshots now saved to disk**: every screenshot action automatically saves both the original full-resolution PNG and the compressed (800px) version to disk — the agent reports both file paths so you can reference, open, or pass the screenshots to other tools
14
+ - **Provider "Get API Key" links in onboarding**: the setup wizard now shows a direct link to the provider's website when you select a provider that has a `website_url` — making it easier to sign up and get your API key without leaving the flow
15
+
16
+ ### Fixed
17
+ - **WebSocket auto-reconnect for Feishu and WeCom channels**: the WebSocket clients for Feishu and WeCom now automatically retry the connection after failures — channels stay online without manual intervention after a network hiccup
18
+ - **Brand command in simple install script**: the `clacky` brand command was incorrectly invoked in `install_simple.sh` — now fixed so the post-install branding step runs correctly
19
+ - **Windows WSL2 and Hyper-V detection in PowerShell installer**: improved detection logic for WSL2 and Hyper-V environments in `install.ps1`, reducing false negatives on Windows machines with non-standard configurations
20
+
10
21
  ## [0.9.16] - 2026-03-27
11
22
 
12
23
  ### Fixed
@@ -17,7 +17,8 @@ module Clacky
17
17
  "base_url" => "https://openrouter.ai/api/v1",
18
18
  "api" => "openai-responses",
19
19
  "default_model" => "anthropic/claude-sonnet-4-6",
20
- "models" => [] # Dynamic - fetched from API
20
+ "models" => [], # Dynamic - fetched from API
21
+ "website_url" => "https://openrouter.ai/keys"
21
22
  }.freeze,
22
23
 
23
24
  "minimax" => {
@@ -25,7 +26,8 @@ module Clacky
25
26
  "base_url" => "https://api.minimaxi.com/v1",
26
27
  "api" => "openai-completions",
27
28
  "default_model" => "MiniMax-M2.7",
28
- "models" => ["MiniMax-M2.5", "MiniMax-M2.7"]
29
+ "models" => ["MiniMax-M2.5", "MiniMax-M2.7"],
30
+ "website_url" => "https://www.minimaxi.com/user-center/basic-information/interface-key"
29
31
  }.freeze,
30
32
 
31
33
  "kimi" => {
@@ -33,7 +35,8 @@ module Clacky
33
35
  "base_url" => "https://api.moonshot.cn/v1",
34
36
  "api" => "openai-completions",
35
37
  "default_model" => "kimi-k2.5",
36
- "models" => ["kimi-k2.5"]
38
+ "models" => ["kimi-k2.5"],
39
+ "website_url" => "https://platform.moonshot.cn/console/api-keys"
37
40
  }.freeze,
38
41
 
39
42
  "anthropic" => {
@@ -41,7 +44,8 @@ module Clacky
41
44
  "base_url" => "https://api.anthropic.com",
42
45
  "api" => "anthropic-messages",
43
46
  "default_model" => "claude-sonnet-4.6",
44
- "models" => ["claude-opus-4-6", "claude-sonnet-4.6", "claude-haiku-4.5"]
47
+ "models" => ["claude-opus-4-6", "claude-sonnet-4.6", "claude-haiku-4.5"],
48
+ "website_url" => "https://console.anthropic.com/settings/keys"
45
49
  }.freeze,
46
50
 
47
51
  "bedrock-jp" => {
@@ -52,7 +56,8 @@ module Clacky
52
56
  "models" => [
53
57
  "jp.anthropic.claude-sonnet-4-6",
54
58
  "jp.anthropic.claude-haiku-4-6"
55
- ]
59
+ ],
60
+ "website_url" => "https://console.aws.amazon.com/iam/home#/security_credentials"
56
61
  }.freeze
57
62
 
58
63
  }.freeze
@@ -53,6 +53,11 @@ module Clacky
53
53
 
54
54
  private
55
55
 
56
+ # Timeout for IO.select on the read loop. Feishu server sends pings every
57
+ # @ping_interval seconds (default 90s). Allow two missed pings before
58
+ # treating the connection as dead.
59
+ READ_TIMEOUT_MULTIPLIER = 2.5
60
+
56
61
  def connect_and_listen
57
62
  Clacky::Logger.info("[feishu-ws] Fetching WebSocket endpoint...")
58
63
  endpoint = fetch_ws_endpoint
@@ -92,9 +97,22 @@ module Clacky
92
97
 
93
98
  start_ping_thread
94
99
 
100
+ # read_timeout is based on the server-provided ping interval so it
101
+ # automatically adapts if Feishu changes the cadence.
102
+ read_timeout = (@ping_interval * READ_TIMEOUT_MULTIPLIER).ceil
103
+
95
104
  loop do
96
105
  break unless @running
97
- data = socket.readpartial(4096)
106
+
107
+ # Use IO.select with a timeout to detect silent connection drops
108
+ # (NAT expiry, firewall idle-kill) that never send a TCP FIN/RST.
109
+ ready = IO.select([socket], nil, nil, read_timeout)
110
+ unless ready
111
+ Clacky::Logger.warn("[feishu-ws] read timeout (#{read_timeout}s), reconnecting...")
112
+ return
113
+ end
114
+
115
+ data = socket.read_nonblock(4096)
98
116
  @incoming << data
99
117
  while (frame = @incoming.next)
100
118
  case frame.type
@@ -106,13 +124,14 @@ module Clacky
106
124
  when :ping
107
125
  send_raw_frame(:pong, frame.data)
108
126
  when :close
109
- Clacky::Logger.info("[feishu-ws] WebSocket closed, will reconnect")
127
+ Clacky::Logger.info("[feishu-ws] WebSocket closed by server, will reconnect")
110
128
  return
111
129
  end
112
130
  end
113
131
  end
114
- rescue EOFError, Errno::ECONNRESET
115
- Clacky::Logger.warn("[feishu-ws] Connection lost, reconnecting in #{RECONNECT_DELAY}s...")
132
+ rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE,
133
+ Errno::ETIMEDOUT, OpenSSL::SSL::SSLError => e
134
+ Clacky::Logger.warn("[feishu-ws] Connection lost (#{e.class}: #{e.message}), reconnecting in #{RECONNECT_DELAY}s...")
116
135
  ensure
117
136
  @ws_open = false
118
137
  @ws_socket = nil
@@ -254,7 +273,10 @@ module Clacky
254
273
  headers: { "type" => "ping" }
255
274
  )
256
275
  rescue => e
257
- warn "[feishu-ws] ping failed: #{e.message}"
276
+ Clacky::Logger.warn("[feishu-ws] ping failed (#{e.class}: #{e.message}), forcing reconnect")
277
+ # Close the socket so IO.select in the read loop immediately
278
+ # returns nil / read_nonblock raises IOError, triggering reconnect.
279
+ @ws_socket&.close rescue nil
258
280
  break
259
281
  end
260
282
  end
@@ -117,6 +117,13 @@ module Clacky
117
117
 
118
118
  private
119
119
 
120
+ # Timeout for IO.select on the read loop. If no data arrives within this
121
+ # window we treat the connection as dead and reconnect. This catches the
122
+ # silent-drop case where the TCP stack never delivers a FIN/RST (e.g.
123
+ # NAT timeout, firewall idle-kill). The WeCom server sends pings every
124
+ # ~30 s, so 75 s gives two missed pings before we give up.
125
+ READ_TIMEOUT_S = 75
126
+
120
127
  def connect_and_listen
121
128
  uri = URI.parse(@ws_url)
122
129
  port = uri.port || 443
@@ -151,7 +158,17 @@ module Clacky
151
158
 
152
159
  loop do
153
160
  break unless @running
154
- data = ssl.readpartial(4096)
161
+
162
+ # Use IO.select with a timeout so we detect silent connection drops
163
+ # (e.g. NAT expiry) that never deliver a TCP FIN/RST. Without this,
164
+ # readpartial blocks forever and the thread hangs permanently.
165
+ ready = IO.select([ssl], nil, nil, READ_TIMEOUT_S)
166
+ unless ready
167
+ Clacky::Logger.warn("[WecomWSClient] read timeout (#{READ_TIMEOUT_S}s), reconnecting...")
168
+ return
169
+ end
170
+
171
+ data = ssl.read_nonblock(4096)
155
172
  @incoming << data
156
173
  while (frame = @incoming.next)
157
174
  case frame.type
@@ -160,13 +177,14 @@ module Clacky
160
177
  when :ping
161
178
  send_raw_frame(:pong, frame.data)
162
179
  when :close
163
- Clacky::Logger.info("[WecomWSClient] connection closed")
180
+ Clacky::Logger.info("[WecomWSClient] connection closed by server")
164
181
  return
165
182
  end
166
183
  end
167
184
  end
168
- rescue EOFError, Errno::ECONNRESET
169
- Clacky::Logger.info("[WecomWSClient] connection lost, reconnecting...")
185
+ rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE,
186
+ Errno::ETIMEDOUT, OpenSSL::SSL::SSLError => e
187
+ Clacky::Logger.info("[WecomWSClient] connection lost (#{e.class}: #{e.message}), reconnecting...")
170
188
  ensure
171
189
  @ws_open = false
172
190
  @ws_socket = nil
@@ -257,7 +275,15 @@ module Clacky
257
275
  loop do
258
276
  sleep HEARTBEAT_INTERVAL
259
277
  break unless @running
260
- send_frame(cmd: "ping", req_id: generate_req_id("ping"))
278
+ begin
279
+ send_frame(cmd: "ping", req_id: generate_req_id("ping"))
280
+ rescue => e
281
+ Clacky::Logger.warn("[WecomWSClient] ping failed (#{e.class}: #{e.message}), forcing reconnect")
282
+ # Close the socket so IO.select in the read loop immediately
283
+ # returns nil / read_nonblock raises IOError, triggering reconnect.
284
+ @ws_socket&.close rescue nil
285
+ break
286
+ end
261
287
  end
262
288
  end
263
289
  end
@@ -1528,7 +1528,8 @@ module Clacky
1528
1528
  name: preset["name"],
1529
1529
  base_url: preset["base_url"],
1530
1530
  default_model: preset["default_model"],
1531
- models: preset["models"] || []
1531
+ models: preset["models"] || [],
1532
+ website_url: preset["website_url"]
1532
1533
  }
1533
1534
  end
1534
1535
  json_response(res, 200, { providers: providers })
@@ -6,6 +6,9 @@ require "timeout"
6
6
  require "tmpdir"
7
7
  require "shellwords"
8
8
  require "yaml"
9
+ require "base64"
10
+ require "fileutils"
11
+ require "securerandom"
9
12
  require_relative "base"
10
13
 
11
14
  module Clacky
@@ -159,11 +162,20 @@ module Clacky
159
162
  action = result[:action].to_s
160
163
 
161
164
  if action == "screenshot" && result[:image_data]
162
- mime_type = result[:mime_type] || "image/jpeg"
163
- image_data = result[:image_data]
164
- data_url = "data:#{mime_type};base64,#{image_data}"
165
+ mime_type = result[:mime_type] || "image/png"
166
+ image_data = result[:image_data]
167
+ data_url = "data:#{mime_type};base64,#{image_data}"
168
+ original_path = result[:original_path]
169
+ compressed_path = result[:compressed_path]
170
+
171
+ text = "Screenshot captured."
172
+ if original_path || compressed_path
173
+ text += "\n- Original (full resolution): #{original_path || 'unavailable'}" \
174
+ "\n- Compressed (800px, sent to AI): #{compressed_path || 'unavailable'}"
175
+ end
176
+
165
177
  return [
166
- { type: "text", text: "Screenshot captured." },
178
+ { type: "text", text: text },
167
179
  { type: "image_url", image_url: { url: data_url } }
168
180
  ]
169
181
  end
@@ -416,6 +428,9 @@ module Clacky
416
428
  output: text.empty? ? "Screenshot captured." : text }
417
429
  end
418
430
 
431
+ # Save original (full-resolution) PNG to disk before any downscaling
432
+ original_path = save_screenshot_to_disk(image_block["data"], suffix: "original")
433
+
419
434
  image_data = png_downscale_base64(image_block["data"], SCREENSHOT_MAX_WIDTH)
420
435
 
421
436
  if image_data.bytesize > SCREENSHOT_MAX_BASE64_BYTES
@@ -424,8 +439,13 @@ module Clacky
424
439
  output: "Screenshot too large after resize (#{size_kb}KB). Use action=snapshot instead." }
425
440
  end
426
441
 
442
+ # Save compressed (800px) PNG for AI reference
443
+ compressed_path = save_screenshot_to_disk(image_data, suffix: "compressed")
444
+
427
445
  { action: "screenshot", success: true, profile: "user",
428
- image_data: image_data, mime_type: "image/png", output: "Screenshot captured." }
446
+ image_data: image_data, mime_type: "image/png",
447
+ original_path: original_path, compressed_path: compressed_path,
448
+ output: "Screenshot captured." }
429
449
  end
430
450
 
431
451
  private def png_downscale_base64(b64, max_width)
@@ -443,6 +463,25 @@ module Clacky
443
463
  result
444
464
  end
445
465
 
466
+ # Save a base64-encoded PNG screenshot to disk and return the file path.
467
+ # suffix: "original" or "compressed" — embedded in filename for clarity.
468
+ # Uses the same upload directory as other image files so the agent can
469
+ # reference, read, or pass the path to other tools.
470
+ private def save_screenshot_to_disk(base64_data, suffix: nil)
471
+ upload_dir = File.join(Dir.tmpdir, "clacky-uploads")
472
+ FileUtils.mkdir_p(upload_dir)
473
+ ts = Time.now.strftime("%Y%m%d_%H%M%S")
474
+ hex = SecureRandom.hex(4)
475
+ label = suffix ? "_#{suffix}" : ""
476
+ filename = "screenshot_#{ts}_#{hex}#{label}.png"
477
+ path = File.join(upload_dir, filename)
478
+ File.binwrite(path, Base64.strict_decode64(base64_data))
479
+ path
480
+ rescue => e
481
+ Clacky::Logger.error("screenshot_save_failed", error: e.message)
482
+ nil
483
+ end
484
+
446
485
  # -----------------------------------------------------------------------
447
486
  # Chrome MCP
448
487
  # -----------------------------------------------------------------------
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.9.16"
4
+ VERSION = "0.9.17"
5
5
  end
@@ -181,6 +181,7 @@ const I18n = (() => {
181
181
  "settings.models.field.model": "Model",
182
182
  "settings.models.field.baseurl": "Base URL",
183
183
  "settings.models.field.apikey": "API Key",
184
+ "settings.models.field.getApiKey": "How to get →",
184
185
  "settings.models.placeholder.provider": "— Choose provider —",
185
186
  "settings.models.placeholder.model": "e.g. claude-sonnet-4-5",
186
187
  "settings.models.placeholder.baseurl": "https://api.anthropic.com",
@@ -254,6 +255,7 @@ const I18n = (() => {
254
255
  "onboard.key.model": "Model",
255
256
  "onboard.key.baseurl": "Base URL",
256
257
  "onboard.key.apikey": "API Key",
258
+ "onboard.key.getApiKey": "How to get →",
257
259
  "onboard.key.btn.test": "Test & Continue →",
258
260
  "onboard.key.btn.back": "← Back",
259
261
  "onboard.provider.custom": "Custom",
@@ -454,6 +456,7 @@ const I18n = (() => {
454
456
  "settings.models.field.model": "Model",
455
457
  "settings.models.field.baseurl": "Base URL",
456
458
  "settings.models.field.apikey": "API Key",
459
+ "settings.models.field.getApiKey": "如何获取 →",
457
460
  "settings.models.placeholder.provider": "— 选择服务商 —",
458
461
  "settings.models.placeholder.model": "如 claude-sonnet-4-5",
459
462
  "settings.models.placeholder.baseurl": "https://api.anthropic.com",
@@ -527,6 +530,7 @@ const I18n = (() => {
527
530
  "onboard.key.model": "模型",
528
531
  "onboard.key.baseurl": "Base URL",
529
532
  "onboard.key.apikey": "API Key",
533
+ "onboard.key.getApiKey": "如何获取 →",
530
534
  "onboard.key.btn.test": "测试并继续 →",
531
535
  "onboard.key.btn.back": "← 返回",
532
536
  "onboard.provider.custom": "自定义",
@@ -553,7 +553,10 @@
553
553
  </div>
554
554
 
555
555
  <div class="setup-field">
556
- <label class="setup-label" data-i18n="onboard.key.apikey">API Key</label>
556
+ <label class="setup-label">
557
+ <span data-i18n="onboard.key.apikey">API Key</span>
558
+ <a id="setup-get-apikey-link" href="#" target="_blank" rel="noopener" style="display:none;margin-left:8px;font-size:12px;color:var(--accent,#6366f1);text-decoration:none;opacity:0.85;" data-i18n="onboard.key.getApiKey">How to get →</a>
559
+ </label>
557
560
  <div class="setup-input-row">
558
561
  <input id="setup-api-key" type="password" class="setup-input" data-i18n-placeholder="settings.models.placeholder.apikey" placeholder="sk-…">
559
562
  <button id="setup-toggle-key" class="btn-toggle-key" title="Show/hide">
@@ -180,16 +180,27 @@ const Onboard = (() => {
180
180
  dropdown.classList.remove("open");
181
181
  trigger.classList.remove("open");
182
182
 
183
+ const getApiKeyLink = $("setup-get-apikey-link");
183
184
  if (value === "__custom__") {
184
185
  // Custom: clear presets so the user can fill in their own values
185
186
  $("setup-model").value = "";
186
187
  $("setup-base-url").value = "";
188
+ if (getApiKeyLink) getApiKeyLink.style.display = "none";
187
189
  } else if (value) {
188
190
  const preset = _providers.find(p => p.id === value);
189
191
  if (preset) {
190
192
  $("setup-model").value = preset.default_model || "";
191
193
  $("setup-base-url").value = preset.base_url || "";
194
+ // Show "how to get" link if provider has a website_url
195
+ if (getApiKeyLink && preset.website_url) {
196
+ getApiKeyLink.href = preset.website_url;
197
+ getApiKeyLink.style.display = "";
198
+ } else if (getApiKeyLink) {
199
+ getApiKeyLink.style.display = "none";
200
+ }
192
201
  }
202
+ } else if (getApiKeyLink) {
203
+ getApiKeyLink.style.display = "none";
193
204
  }
194
205
  });
195
206
 
@@ -105,7 +105,10 @@ const Settings = (() => {
105
105
  placeholder="${I18n.t("settings.models.placeholder.baseurl")}" value="${_esc(model.base_url)}">
106
106
  </label>
107
107
  <label class="model-field">
108
- <span class="field-label">${I18n.t("settings.models.field.apikey")}</span>
108
+ <span class="field-label">
109
+ ${I18n.t("settings.models.field.apikey")}
110
+ <a class="get-apikey-link" data-index="${index}" href="#" target="_blank" rel="noopener" style="display:none;margin-left:8px;font-size:12px;color:var(--accent,#6366f1);text-decoration:none;opacity:0.85;">${I18n.t("settings.models.field.getApiKey")}</a>
111
+ </span>
109
112
  <div class="field-input-row">
110
113
  <input type="password" class="field-input api-key-input" data-key="api_key" data-index="${index}"
111
114
  placeholder="${I18n.t("settings.models.placeholder.apikey")}" value="${_esc(model.api_key_masked)}">
@@ -179,6 +182,7 @@ const Settings = (() => {
179
182
  trigger.classList.remove("open");
180
183
 
181
184
  // Auto-fill model & base_url if a provider preset was selected
185
+ const getApiKeyLink = card.querySelector(`.get-apikey-link[data-index="${index}"]`);
182
186
  if (value && value !== "custom") {
183
187
  const preset = _providers.find(p => p.id === value);
184
188
  if (preset) {
@@ -186,7 +190,16 @@ const Settings = (() => {
186
190
  const baseUrlInput = card.querySelector(`[data-key="base_url"]`);
187
191
  if (modelInput) modelInput.value = preset.default_model || "";
188
192
  if (baseUrlInput) baseUrlInput.value = preset.base_url || "";
193
+ // Show "how to get" link if provider has a website_url
194
+ if (getApiKeyLink && preset.website_url) {
195
+ getApiKeyLink.href = preset.website_url;
196
+ getApiKeyLink.style.display = "";
197
+ } else if (getApiKeyLink) {
198
+ getApiKeyLink.style.display = "none";
199
+ }
189
200
  }
201
+ } else if (getApiKeyLink) {
202
+ getApiKeyLink.style.display = "none";
190
203
  }
191
204
  });
192
205
  });
data/scripts/install.ps1 CHANGED
@@ -1,6 +1,6 @@
1
1
  #Requires -Version 5
2
2
  # OpenClacky Windows Installation Script
3
- # Usage: powershell -c "irm https://oss.1024code.com/install.ps1 | iex"
3
+ # Usage: powershell -c "irm https://oss.1024code.com/clacky-ai/openclacky/main/scripts/install.ps1 | iex"
4
4
  #
5
5
  # If WSL is not installed, this script will install it and ask you to reboot.
6
6
  # After rebooting, run the same command again to complete installation.
@@ -9,10 +9,11 @@ Set-StrictMode -Version Latest
9
9
  $ErrorActionPreference = "Stop"
10
10
 
11
11
  $CLACKY_CDN_BASE_URL = "https://oss.1024code.com"
12
- $INSTALL_PS1_COMMAND = "powershell -c `"irm $CLACKY_CDN_BASE_URL/install.ps1 | iex`""
13
- $INSTALL_SCRIPT_URL = "$CLACKY_CDN_BASE_URL/install.sh"
12
+ $INSTALL_PS1_COMMAND = "powershell -c `"irm $CLACKY_CDN_BASE_URL/clacky-ai/openclacky/main/scripts/install.ps1 | iex`""
13
+ $INSTALL_SCRIPT_URL = "$CLACKY_CDN_BASE_URL/clacky-ai/openclacky/main/scripts/install_simple.sh"
14
14
  $UBUNTU_WSL_URL = "$CLACKY_CDN_BASE_URL/ubuntu-jammy-wsl-amd64-ubuntu22.04lts.rootfs.tar.gz"
15
- $WSL_UPDATE_URL = "$CLACKY_CDN_BASE_URL/wsl_update_x64.msi"
15
+ $WSL_UPDATE_URL = "$CLACKY_CDN_BASE_URL/wsl_update_x64.msi" # Windows 10
16
+ $WSL_UPDATE_URL_WIN11 = "$CLACKY_CDN_BASE_URL/wsl.2.6.3.0.x64.msi" # Windows 11
16
17
  $UBUNTU_WSL_DIR = "C:\WSL\Ubuntu"
17
18
 
18
19
  # ---------------------------------------------------------------------------
@@ -34,27 +35,28 @@ function Test-IsAdmin {
34
35
  }
35
36
 
36
37
  # exit 1 = WSL feature not enabled (stub wsl.exe)
37
- # exit -1 = feature enabled but kernel missing
38
38
  # exit 0 = fully functional
39
- function Test-WslFeatureEnabled {
40
- & wsl.exe --list 2>&1 | Out-Null
41
- return ($LASTEXITCODE -ne 1)
42
- }
43
-
44
- # Returns $true if WSL2 kernel is present (exit 0)
45
- function Test-WslKernel {
46
- & wsl.exe --list 2>&1 | Out-Null
47
- return ($LASTEXITCODE -eq 0)
39
+ #
40
+ # Use cmd.exe to run wsl and discard stderr: native wsl.exe writes localized
41
+ # install hints to stderr; in Windows PowerShell 5.x with $ErrorActionPreference
42
+ # Stop, stderr merged via 2>&1 becomes NativeCommandError and can terminate the
43
+ # script (and mojibake when console encoding mismatches).
44
+ function Invoke-WslListExitCode {
45
+ cmd.exe /c "wsl.exe --list 1>nul 2>nul"
46
+ return $LASTEXITCODE
48
47
  }
49
48
 
50
49
  # Returns $true if an Ubuntu distro is already registered
50
+ # wsl --list outputs UTF-16LE; temporarily switch OutputEncoding to decode correctly
51
51
  function Test-UbuntuInstalled {
52
+ $prev = [Console]::OutputEncoding
53
+ [Console]::OutputEncoding = [System.Text.Encoding]::Unicode
52
54
  try {
53
- $out = & wsl --list --quiet 2>&1 | Out-String
54
- return ($out -match '(?im)^ubuntu')
55
- } catch {
56
- return $false
55
+ $out = (wsl.exe --list --quiet 2>$null) -join "`n"
56
+ } finally {
57
+ [Console]::OutputEncoding = $prev
57
58
  }
59
+ return ($out -match '(?im)^ubuntu')
58
60
  }
59
61
 
60
62
  function Prompt-Reboot {
@@ -63,28 +65,52 @@ function Prompt-Reboot {
63
65
  Write-Warn "After restarting, run the same command again:"
64
66
  Write-Host " $INSTALL_PS1_COMMAND" -ForegroundColor Yellow
65
67
  Write-Host ""
68
+ Read-Host "Press Enter to exit"
66
69
  exit 0
67
70
  }
68
71
 
69
- # Enable WSL via dism — works offline, no dependency on Microsoft download servers
72
+ # Enable WSL via dism — works offline, no dependency on Microsoft download servers.
73
+ # Also installs the WSL2 kernel MSI immediately so that after reboot wsl.exe is
74
+ # fully functional and won't show the "WSL must be updated" interactive prompt,
75
+ # which would cause this script to loop endlessly on re-run.
70
76
  function Enable-WslFeatures {
71
77
  Write-Step "Enabling WSL components (requires admin)..."
72
78
  dism /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart | Out-Null
73
79
  dism /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart | Out-Null
74
80
  Write-Success "WSL components enabled."
81
+
82
+ # Install kernel MSI right away — without this the stub wsl.exe keeps
83
+ # returning exit code 1 ("must be updated") after every reboot, causing
84
+ # this script to re-enable features and prompt for reboot in a loop.
85
+ Install-WslKernel
86
+
75
87
  Prompt-Reboot
76
88
  }
77
89
 
78
90
  function Install-WslKernel {
91
+ $build = [System.Environment]::OSVersion.Version.Build
92
+
93
+ # WSL2 requires Windows 10 build 19041 (version 2004) or later
94
+ if ($build -lt 19041) {
95
+ Write-Fail "Your Windows version is too old to run OpenClacky."
96
+ Write-Fail "Please upgrade to Windows 10 (2020 or later) or Windows 11."
97
+ exit 1
98
+ }
99
+
100
+ # Windows 11 (build >= 22000) requires the new full WSL2 package MSI;
101
+ # the legacy wsl_update_x64.msi (v5.x) fails with error 1603 on Windows 11.
102
+ $isWin11 = ($build -ge 22000)
103
+ $url = if ($isWin11) { $WSL_UPDATE_URL_WIN11 } else { $WSL_UPDATE_URL }
104
+
79
105
  $msiPath = "$env:TEMP\wsl_update.msi"
80
106
  Write-Step "Downloading WSL2 kernel update..."
81
107
  $curlOk = $false
82
- try { curl -L --progress-bar $WSL_UPDATE_URL -o $msiPath; $curlOk = ($LASTEXITCODE -eq 0) } catch {}
108
+ try { curl -L --progress-bar $url -o $msiPath; $curlOk = ($LASTEXITCODE -eq 0) } catch {}
83
109
  if (-not $curlOk) {
84
- Invoke-WebRequest -Uri $WSL_UPDATE_URL -OutFile $msiPath -UseBasicParsing
110
+ Invoke-WebRequest -Uri $url -OutFile $msiPath -UseBasicParsing
85
111
  }
86
112
  Write-Info "Download complete. Installing WSL2 kernel..."
87
- Start-Process msiexec -Verb RunAs -Wait -ArgumentList "/i","$msiPath","/quiet","/norestart"
113
+ Start-Process msiexec -Wait -ArgumentList "/i","$msiPath","/quiet","/norestart"
88
114
  Write-Success "WSL2 kernel installed."
89
115
  }
90
116
 
@@ -112,28 +138,39 @@ function Install-Ubuntu {
112
138
  }
113
139
 
114
140
  function Install-Wsl {
115
- if (-not (Test-IsAdmin)) {
116
- Write-Fail "Please re-run this script as Administrator:"
117
- Write-Host ""
118
- Write-Host " Right-click PowerShell -> 'Run as administrator', then:" -ForegroundColor Yellow
119
- Write-Host " $INSTALL_PS1_COMMAND" -ForegroundColor Yellow
120
- exit 1
121
- }
141
+ Write-Step "Checking WSL status..."
142
+ $wslCode = Invoke-WslListExitCode
143
+ Write-Info "WSL check result: exit code $wslCode"
122
144
 
123
- if (-not (Test-WslFeatureEnabled)) {
145
+ if ($wslCode -eq 1) {
124
146
  Enable-WslFeatures
125
147
  # Enable-WslFeatures exits after prompting reboot
126
148
  }
127
149
 
128
- if (-not (Test-WslKernel)) {
129
- Install-WslKernel
130
- }
131
-
132
150
  if (-not (Test-UbuntuInstalled)) {
133
151
  Install-Ubuntu
134
152
  }
135
153
  }
136
154
 
155
+ # ---------------------------------------------------------------------------
156
+ # Ensure Hyper-V hypervisor is active (required for WSL2)
157
+ # ---------------------------------------------------------------------------
158
+ function Ensure-HyperV {
159
+ $hypervisorPresent = (Get-CimInstance -ClassName Win32_ComputerSystem).HypervisorPresent
160
+ if ($hypervisorPresent) { return }
161
+
162
+ Write-Step "Enabling Hyper-V hypervisor..."
163
+ bcdedit /set hypervisorlaunchtype auto | Out-Null
164
+ Write-Success "Hyper-V enabled."
165
+ Write-Host ""
166
+ Write-Warn "A restart is required to apply the changes."
167
+ Write-Warn "After restarting, run the same command again:"
168
+ Write-Host " $INSTALL_PS1_COMMAND" -ForegroundColor Yellow
169
+ Write-Host ""
170
+ Read-Host "Press Enter to restart now"
171
+ Restart-Computer -Force
172
+ }
173
+
137
174
  # ---------------------------------------------------------------------------
138
175
  # Install OpenClacky inside WSL
139
176
  # ---------------------------------------------------------------------------
@@ -173,6 +210,16 @@ Write-Host ""
173
210
  Write-Host "OpenClacky Installation Script (Windows)" -ForegroundColor Cyan
174
211
  Write-Host ""
175
212
 
213
+ # All subsequent operations (bcdedit, dism, wsl) require admin privileges
214
+ if (-not (Test-IsAdmin)) {
215
+ Write-Fail "Please re-run this script as Administrator:"
216
+ Write-Host ""
217
+ Write-Host " Right-click PowerShell -> 'Run as administrator', then:" -ForegroundColor Yellow
218
+ Write-Host " $INSTALL_PS1_COMMAND" -ForegroundColor Yellow
219
+ exit 1
220
+ }
221
+
222
+ Ensure-HyperV
176
223
  Install-Wsl
177
224
 
178
225
  Write-Success "WSL is ready."
@@ -434,18 +434,19 @@ setup_gem_home() {
434
434
 
435
435
  export GEM_HOME="$HOME/.gem/ruby/${ruby_api}"
436
436
  export GEM_PATH="$HOME/.gem/ruby/${ruby_api}"
437
- export PATH="$HOME/.gem/ruby/${ruby_api}/bin:$PATH"
437
+ export PATH="$HOME/.local/bin:$HOME/.gem/ruby/${ruby_api}/bin:$PATH"
438
438
 
439
439
  print_info "System Ruby detected — gems will install to ~/.gem/ruby/${ruby_api}"
440
440
 
441
441
  # Persist to shell rc (use $HOME so the line is portable)
442
+ # Also add ~/.local/bin so brand wrapper commands installed there are found
442
443
  if [ -n "$SHELL_RC" ] && ! grep -q "GEM_HOME" "$SHELL_RC" 2>/dev/null; then
443
444
  {
444
445
  echo ""
445
446
  echo "# Ruby user gem dir (added by openclacky installer)"
446
447
  echo "export GEM_HOME=\"\$HOME/.gem/ruby/${ruby_api}\""
447
448
  echo "export GEM_PATH=\"\$HOME/.gem/ruby/${ruby_api}\""
448
- echo "export PATH=\"\$HOME/.gem/ruby/${ruby_api}/bin:\$PATH\""
449
+ echo "export PATH=\"\$HOME/.local/bin:\$HOME/.gem/ruby/${ruby_api}/bin:\$PATH\""
449
450
  } >> "$SHELL_RC"
450
451
  print_info "GEM_HOME written to $SHELL_RC"
451
452
  fi
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.16
4
+ version: 0.9.17
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy