openclacky 1.2.2 → 1.2.3

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: cfaa1f45ce6ab05b0a101571f59e809d92430ee862196b21a714ceb56c3ec8b5
4
- data.tar.gz: 6d76cd4d7bf8568fd8229dc9770f23fb027296c660ad0ba3d514be61df69c5ce
3
+ metadata.gz: 39490977253b82c6bf0e0e7c89e3763b86dbe56a46e8c6e716774c06387bc7a7
4
+ data.tar.gz: 25224b037f948e7252467337a4e04051340d4bd7ff75456d7a8653acfc43e94c
5
5
  SHA512:
6
- metadata.gz: aa5e444bc28d570022df63172027028d99224191f6d2a0b26537cb5c1597c523b165b35efd58747312586aa01f133c522b5c9cae46b5f25d06501b1a083b6e31
7
- data.tar.gz: ae1d58c07b9a24937b1c4182e19a093394565fdc972c5caf72cba66f3bf0419cbdd0874f7b2d741d7b00b8efdd8fe83a2efee767f7df1a3e8e6656b237631721
6
+ metadata.gz: 9ba3260b11c8b7f37c075ebc392567f3b049246932e65e1fc12df99363f86921af28c05e5fd4d46ddb1cd796092fc11315b80ef67937c13b5e6cbb9a276cdc47
7
+ data.tar.gz: 8de209627490544c48f3d07f8b010cc40fe6ef51e597734b61adb7c6285d65d3a309aebebe9f9f563a681db60596b797e5a79d9eaa46c85805b37b75cca9369c
data/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.3] - 2026-05-27
9
+
10
+ ### Added
11
+ - Qwen 3.7 model support
12
+ - WSL network doctor PowerShell script
13
+ - Limit on glob/grep file walker to prevent excessive traversal
14
+
15
+ ### Fixed
16
+ - Terminal single-line mode issue
17
+ - Terminal execution hang
18
+ - apt try logic
19
+
8
20
  ## [1.2.2] - 2026-05-25
9
21
 
10
22
  ### More
@@ -111,6 +111,96 @@ chrome-devtools-mcp --version 2>/dev/null
111
111
 
112
112
  If still missing after user confirms, stop with error message.
113
113
 
114
+ ### Step 2.5 — WSL networking setup (only when session context shows `OS: WSL/Windows`)
115
+
116
+ **Skip this entire step on macOS / Linux.** Look at the session context line that begins with `[Session context: ...]` — only run this step if it includes `OS: WSL/Windows`.
117
+
118
+ #### Background (read this so you know what to do)
119
+
120
+ The browser tool runs inside WSL but Chrome/Edge runs on Windows. By default WSL2 uses NAT networking, which means `127.0.0.1` inside WSL **cannot** reach Windows' Chrome debug port. The fix is to enable WSL2 **mirrored networking** (`networkingMode=mirrored` in `%USERPROFILE%\.wslconfig`), which makes WSL share Windows' network stack so `127.0.0.1` works directly.
121
+
122
+ We have a helper script that handles all the Windows-side details:
123
+
124
+ ```
125
+ ~/.clacky/scripts/wsl_network_doctor.ps1
126
+ ```
127
+
128
+ It exposes three subcommands:
129
+
130
+ | Subcommand | What it does | Exit code |
131
+ |---|---|---|
132
+ | `status` | Check whether mirrored is configured (auto-passes on WSL1) | `0` OK / `10` NEED_ENABLE |
133
+ | `enable` | Write `networkingMode=mirrored` to `.wslconfig` (does NOT shut down WSL) | `0` success / `1` fail |
134
+ | `repair` | Restart Windows Host Network Service (HNS) via UAC prompt | `0` launched / `1` fail |
135
+
136
+ Invoke it from WSL like this:
137
+
138
+ ```bash
139
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(wslpath -w ~/.clacky/scripts/wsl_network_doctor.ps1)" <subcommand>
140
+ ```
141
+
142
+ #### Step 2.5.1 — Check status
143
+
144
+ ```bash
145
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(wslpath -w ~/.clacky/scripts/wsl_network_doctor.ps1)" status
146
+ ```
147
+
148
+ - Exit `0` (output starts with `OK:`) → either mirrored is configured (WSL2) or
149
+ Ubuntu is running on WSL1 (which shares the Windows network stack and needs no
150
+ config). Either way, proceed to Step 3.
151
+ - Exit `10` (output starts with `NEED_ENABLE:`) → continue to Step 2.5.2.
152
+ - Any other failure → show the output to the user and ask them to retry. Stop here.
153
+
154
+ #### Step 2.5.2 — Enable mirrored (only when NEED_ENABLE)
155
+
156
+ Tell the user what's about to happen (in their language):
157
+
158
+ > WSL doesn't have mirrored networking enabled yet — the browser tool needs it to reach Chrome on Windows.
159
+ > I'll add one line to `%USERPROFILE%\.wslconfig`. Your current WSL session will NOT be restarted.
160
+
161
+ Run:
162
+
163
+ ```bash
164
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(wslpath -w ~/.clacky/scripts/wsl_network_doctor.ps1)" enable
165
+ ```
166
+
167
+ If the script exits `0`:
168
+
169
+ > ✅ `.wslconfig` updated. Tell the user (in their language):
170
+ >
171
+ > The config takes effect only after WSL restarts, but we can't restart WSL from inside WSL.
172
+ > Please:
173
+ >
174
+ > 1. Open **PowerShell** on Windows
175
+ > 2. Run: `wsl --shutdown`
176
+ > 3. Reopen the Clacky terminal
177
+ > 4. Run `/browser-setup` again
178
+ >
179
+ > Stop here. Wait for the user to come back in a new session.
180
+
181
+ If the script exits non-zero, show the output to the user and stop. Do NOT proceed to Step 3 — without mirrored networking the browser tool will not work.
182
+
183
+ #### Step 2.5.3 — When to run repair
184
+
185
+ Do NOT run `repair` proactively. Only run it later if **all** of the following are true:
186
+
187
+ - `status` returned `OK` (mirrored is configured)
188
+ - The user has restarted WSL since the config was written
189
+ - Step 3's `browser(action="status")` still fails with a "Chrome/Edge is not running or remote debugging is not enabled" error
190
+
191
+ In that situation, tell the user (in their language):
192
+
193
+ > The config looks correct but the browser still can't connect. Windows Host Network Service may be stuck — I'll restart it.
194
+ > **A Windows User Account Control (UAC) prompt will appear shortly. Please click "Yes".**
195
+
196
+ Then run:
197
+
198
+ ```bash
199
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(wslpath -w ~/.clacky/scripts/wsl_network_doctor.ps1)" repair
200
+ ```
201
+
202
+ After it returns, tell the user to run `wsl --shutdown` in PowerShell and reopen Clacky. Stop and wait.
203
+
114
204
  ### Step 3 — Verify Chrome/Edge is running with remote debugging
115
205
 
116
206
  **CRITICAL**: Do NOT attempt `browser()` calls yet. First check if the browser is reachable using the API:
@@ -335,28 +335,23 @@ module Clacky
335
335
  "name" => "Qwen (Alibaba)",
336
336
  "base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1",
337
337
  "api" => "openai-completions",
338
- "default_model" => "qwen3.6-plus",
338
+ "default_model" => "qwen3.7-max",
339
339
  "models" => [
340
+ "qwen3.7-max",
340
341
  "qwen3.6-plus",
341
342
  "qwen3.6-max",
342
343
  "qwen3.6-27b",
343
344
  "qwen3.6-flash",
344
345
  "qwen-plus-latest",
345
- "qwen-vl-plus",
346
- "qwen-vl-max"
347
346
  ],
348
347
  "endpoint_variants" => [
349
348
  { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://dashscope.aliyuncs.com/compatible-mode/v1", "region" => "cn" }.freeze,
350
349
  { "label" => "Singapore", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "region" => "intl" }.freeze,
351
350
  { "label" => "US (Virginia)", "label_key" => "settings.models.baseurl.variant.us", "base_url" => "https://dashscope-us.aliyuncs.com/compatible-mode/v1", "region" => "us" }.freeze
352
351
  ].freeze,
353
- "capabilities" => { "vision" => false }.freeze,
354
- "model_capabilities" => {
355
- "qwen3.6-27b" => { "vision" => true }.freeze,
356
- "qwen-vl-plus" => { "vision" => true }.freeze,
357
- "qwen-vl-max" => { "vision" => true }.freeze
358
- }.freeze,
352
+ "capabilities" => { "vision" => true }.freeze,
359
353
  "lite_models" => {
354
+ "qwen3.7-max" => "qwen3.6-flash",
360
355
  "qwen3.6-plus" => "qwen3.6-flash",
361
356
  "qwen3.6-max" => "qwen3.6-flash",
362
357
  "qwen3.6-27b" => "qwen3.6-flash",
@@ -49,6 +49,14 @@ module Clacky
49
49
  return { error: "Base path does not exist: #{base_path}" }
50
50
  end
51
51
 
52
+ if Clacky::Utils::FileIgnoreHelper.dangerous_root?(base_path)
53
+ return {
54
+ error: "Refusing to recursively glob from broad path '#{base_path}'. " \
55
+ "Narrow base_path to a specific subdirectory, " \
56
+ "or use '.' to search the working directory."
57
+ }
58
+ end
59
+
52
60
  begin
53
61
  expanded_path = base_path
54
62
 
@@ -70,7 +78,8 @@ module Clacky
70
78
  fnmatch_flags = File::FNM_PATHNAME | File::FNM_DOTMATCH
71
79
 
72
80
  matches = []
73
- Clacky::Utils::FileIgnoreHelper.walk_files(expanded_path, skipped: skipped) do |file|
81
+ walk_status = {}
82
+ Clacky::Utils::FileIgnoreHelper.walk_files(expanded_path, skipped: skipped, status: walk_status) do |file|
74
83
  relative = file[(expanded_path.length + 1)..]
75
84
 
76
85
  unless File.fnmatch(effective_pattern, relative, fnmatch_flags)
@@ -101,11 +110,13 @@ module Clacky
101
110
  # Convert to absolute paths
102
111
  matches = matches.map { |path| File.expand_path(path) }
103
112
 
113
+ walk_truncated = walk_status[:truncated] == true
104
114
  {
105
115
  matches: matches,
106
116
  total_matches: total_matches,
107
117
  returned: matches.length,
108
- truncated: total_matches > limit,
118
+ truncated: total_matches > limit || walk_truncated,
119
+ truncation_reason: walk_status[:truncation_reason],
109
120
  skipped_files: skipped,
110
121
  error: nil
111
122
  }
@@ -129,9 +140,13 @@ module Clacky
129
140
  count = result[:returned] || 0
130
141
  total = result[:total_matches] || 0
131
142
  truncated = result[:truncated] ? " (truncated)" : ""
132
-
143
+
133
144
  msg = "[OK] Found #{count}/#{total} files#{truncated}"
134
-
145
+
146
+ if result[:truncation_reason]
147
+ msg += " [walk #{result[:truncation_reason]}]"
148
+ end
149
+
135
150
  # Add skipped files info if present
136
151
  if result[:skipped_files]
137
152
  skipped = result[:skipped_files]
@@ -92,6 +92,14 @@ module Clacky
92
92
  return { error: "Path does not exist: #{path}" }
93
93
  end
94
94
 
95
+ if File.directory?(expanded_path) && Clacky::Utils::FileIgnoreHelper.dangerous_root?(expanded_path)
96
+ return {
97
+ error: "Refusing to recursively grep from broad path '#{path}'. " \
98
+ "Narrow the path to a specific subdirectory, " \
99
+ "or use '.' to search the working directory."
100
+ }
101
+ end
102
+
95
103
  # Limit context_lines
96
104
  context_lines = [[context_lines, 0].max, 10].min
97
105
 
@@ -115,10 +123,14 @@ module Clacky
115
123
  else
116
124
  fnmatch_flags = File::FNM_PATHNAME | File::FNM_DOTMATCH
117
125
  collected = []
118
- Clacky::Utils::FileIgnoreHelper.walk_files(expanded_path, skipped: skipped) do |f|
126
+ walk_status = {}
127
+ Clacky::Utils::FileIgnoreHelper.walk_files(expanded_path, skipped: skipped, status: walk_status) do |f|
119
128
  relative = f[(expanded_path.length + 1)..]
120
129
  collected << f if File.fnmatch(file_pattern, relative, fnmatch_flags)
121
130
  end
131
+ if walk_status[:truncated]
132
+ truncation_reason ||= "walk #{walk_status[:truncation_reason]}"
133
+ end
122
134
  collected
123
135
  end
124
136
 
@@ -20,7 +20,7 @@ module Clacky
20
20
  # to a shell / PTY for execution):
21
21
  #
22
22
  # 1. Block hard-dangerous commands: sudo, pkill clacky, eval, exec,
23
- # `...`, $(...), | sh, | bash,
23
+ # `...`, | sh, | bash,
24
24
  # redirect to /etc /usr /bin.
25
25
  # 2. Rewrite `curl ... | bash` → save script to a file for manual
26
26
  # review instead of exec.
@@ -246,7 +246,6 @@ module Clacky
246
246
  /exec\s*\(/,
247
247
  /system\s*\(/,
248
248
  /`[^`]+`/,
249
- /\$\([^)]+\)/,
250
249
  /\|\s*sh\s*$/,
251
250
  /\|\s*bash\s*$/,
252
251
  />\s*\/etc\//,
@@ -72,6 +72,9 @@ module Clacky
72
72
  {session_id, input:"pw\n"} reply to prompt / poll (input:"")
73
73
  {session_id, kill:true} stop
74
74
 
75
+ Single-line only. For multi-line scripts (heredoc, loops, multi-statement blocks)
76
+ write a file first, then run it: write(path:"/tmp/run.sh", content:...) → terminal(command:"bash /tmp/run.sh").
77
+
75
78
  Response: exit_code = done; session_id = running (state: waiting/background/timeout).
76
79
  If output exceeds the limit, `output` is truncated and `full_output_file` points
77
80
  at a file on disk — use terminal(command: "grep ... <path>") to search it.
@@ -228,6 +231,17 @@ module Clacky
228
231
 
229
232
  # Start a new command
230
233
  if command && !command.to_s.strip.empty?
234
+ if multiline_command?(command)
235
+ return {
236
+ error: "Multi-line commands are unreliable in our PTY shell " \
237
+ "(heredocs / unclosed quotes / multi-line blocks can hang the session).",
238
+ hint: "Write the script to a file first, then execute it. " \
239
+ "Example: 1) write(path: \"/tmp/run.sh\", content: \"...\") " \
240
+ "2) terminal(command: \"bash /tmp/run.sh\")",
241
+ multiline_blocked: true
242
+ }
243
+ end
244
+
231
245
  return do_start(command.to_s, cwd: cwd, env: env, timeout: timeout,
232
246
  idle_ms: idle_ms, background: background ? true : false)
233
247
  end
@@ -325,6 +339,12 @@ module Clacky
325
339
  project_root: cwd || Dir.pwd
326
340
  )
327
341
 
342
+ # WSL interop fix: Windows .exe processes inherit the PTY's stdin fd
343
+ # and attempt to use it as a Windows Console, causing them to hang
344
+ # indefinitely. Redirect stdin from /dev/null for any .exe invocation
345
+ # that doesn't already have an explicit stdin redirect.
346
+ safe_command = redirect_exe_stdin(safe_command)
347
+
328
348
  # Background / dedicated path — never reuse the persistent shell,
329
349
  # because these commands stay running and would occupy the slot.
330
350
  if background
@@ -768,7 +788,7 @@ module Clacky
768
788
 
769
789
  spawn_env = {
770
790
  "TERM" => "xterm-256color",
771
- "PS1" => "",
791
+ "PS1" => " ",
772
792
  # Prevent our sub-shell from polluting the user's ~/.zsh_history
773
793
  # (or ~/.bash_history). We fork a full interactive login shell to
774
794
  # get rbenv/nvm/brew-shellenv/mise loaded, but every command we
@@ -1179,6 +1199,17 @@ module Clacky
1179
1199
  SLOW_COMMAND_PATTERNS.any? { |pat| s.include?(pat) }
1180
1200
  end
1181
1201
 
1202
+ # True when `command` spans multiple lines. Trailing newlines are
1203
+ # ignored — a single-line command terminated with "\n" is still
1204
+ # single-line. Multi-line commands frequently hang the persistent
1205
+ # PTY shell (incomplete heredoc, unclosed quote, multi-line block
1206
+ # without closer) — the agent should write a script file and
1207
+ # invoke it instead.
1208
+ private def multiline_command?(command)
1209
+ return false if command.nil?
1210
+ command.to_s.sub(/\n+\z/, "").include?("\n")
1211
+ end
1212
+
1182
1213
  # Apply per-line truncation to a cleaned (post-OutputCleaner) string.
1183
1214
  # If any single line exceeds MAX_LINE_CHARS, we chop it at that length
1184
1215
  # and append `…[line truncated: <original> chars]` so the LLM knows
@@ -1358,6 +1389,16 @@ module Clacky
1358
1389
  return "" if lines.empty?
1359
1390
  lines.last(DISPLAY_TAIL_LINES).join("\n")
1360
1391
  end
1392
+
1393
+ # WSL interop fix: Windows .exe processes inherit the PTY stdin fd
1394
+ # and try to use it as a Windows Console, which hangs indefinitely.
1395
+ # Detect .exe invocations and redirect stdin from /dev/null unless
1396
+ # the command already has an explicit stdin redirect.
1397
+ private def redirect_exe_stdin(command)
1398
+ return command unless command =~ /\.exe\b/i
1399
+ return command if command =~ /<\s*[^\s|&;]/
1400
+ "#{command} </dev/null"
1401
+ end
1361
1402
  end
1362
1403
  end
1363
1404
  end
@@ -116,21 +116,94 @@ module Clacky
116
116
  CONFIG_FILE_PATTERNS.any? { |pattern| file.match?(pattern) }
117
117
  end
118
118
 
119
+ # Paths considered too broad to recursively walk by default. Searching from
120
+ # these would commonly traverse millions of files (system roots, $HOME with
121
+ # many workspaces, WSL Windows mounts). Tools should refuse such requests
122
+ # and ask for a narrower base_path.
123
+ def self.dangerous_root?(path)
124
+ return false if path.nil? || path.empty?
125
+
126
+ expanded = File.expand_path(path)
127
+ return true if expanded == "/"
128
+
129
+ system_roots = ["/root", "/home", "/Users", "/mnt", "/media", "/var", "/etc", "/usr", "/opt"]
130
+ return true if system_roots.include?(expanded)
131
+
132
+ ["/Users/", "/home/"].each do |prefix|
133
+ next unless expanded.start_with?(prefix)
134
+ tail = expanded[prefix.length..]
135
+ return true if tail && !tail.empty? && !tail.include?("/")
136
+ end
137
+
138
+ return true if expanded =~ %r{\A/mnt/[a-zA-Z]\z}
139
+
140
+ home = ENV["HOME"]
141
+ return true if home && !home.empty? && expanded == File.expand_path(home)
142
+
143
+ false
144
+ end
145
+
146
+ # Hard ceiling on directories visited in a single walk. Prevents indefinite
147
+ # traversal across huge trees (e.g. /root, $HOME, /mnt/c on WSL).
148
+ MAX_DIRS_VISITED = 20_000
149
+
150
+ # Wall-clock budget for a single walk, in seconds.
151
+ WALK_TIMEOUT_SECONDS = 15
152
+
153
+ # Raised internally to abort a walk when a budget is exhausted.
154
+ class WalkBudgetExceeded < StandardError
155
+ attr_reader :reason
156
+ def initialize(reason)
157
+ @reason = reason
158
+ super(reason.to_s)
159
+ end
160
+ end
161
+
119
162
  # Walk a directory tree, pruning ignored directories early.
120
163
  # Yields each non-ignored file path. Supports nested .gitignore files.
121
164
  # @param skipped [Hash, nil] If provided, increments :ignored for each gitignore-skipped entry.
122
- def self.walk_files(base_path, gitignore: nil, skipped: nil, &block)
123
- return enum_for(:walk_files, base_path, gitignore: gitignore, skipped: skipped) unless block_given?
165
+ # @param status [Hash, nil] If provided, populated with :truncated and :truncation_reason
166
+ # when the walk is aborted due to dir-count or wall-clock budget.
167
+ def self.walk_files(base_path, gitignore: nil, skipped: nil, status: nil,
168
+ max_dirs_visited: MAX_DIRS_VISITED,
169
+ timeout_seconds: WALK_TIMEOUT_SECONDS,
170
+ &block)
171
+ unless block_given?
172
+ return enum_for(:walk_files, base_path,
173
+ gitignore: gitignore, skipped: skipped, status: status,
174
+ max_dirs_visited: max_dirs_visited, timeout_seconds: timeout_seconds)
175
+ end
124
176
 
125
177
  root_gitignore = gitignore || begin
126
178
  gi_path = find_gitignore(base_path)
127
179
  gi_path ? Clacky::GitignoreParser.new(gi_path) : nil
128
180
  end
129
181
 
130
- _walk_recursive(base_path, base_path, root_gitignore, skipped, &block)
182
+ budget = {
183
+ dirs_visited: 0,
184
+ max_dirs: max_dirs_visited,
185
+ deadline: timeout_seconds ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds) : nil
186
+ }
187
+
188
+ begin
189
+ _walk_recursive(base_path, base_path, root_gitignore, skipped, budget, &block)
190
+ rescue WalkBudgetExceeded => e
191
+ if status
192
+ status[:truncated] = true
193
+ status[:truncation_reason] = e.reason.to_s
194
+ end
195
+ end
131
196
  end
132
197
 
133
- def self._walk_recursive(dir, base_path, gitignore, skipped, &block)
198
+ def self._walk_recursive(dir, base_path, gitignore, skipped, budget, &block)
199
+ budget[:dirs_visited] += 1
200
+ if budget[:dirs_visited] > budget[:max_dirs]
201
+ raise WalkBudgetExceeded.new(:max_dirs_visited)
202
+ end
203
+ if budget[:deadline] && Process.clock_gettime(Process::CLOCK_MONOTONIC) > budget[:deadline]
204
+ raise WalkBudgetExceeded.new(:timeout)
205
+ end
206
+
134
207
  child_gitignore_path = File.join(dir, ".gitignore")
135
208
  if dir != base_path && File.exist?(child_gitignore_path)
136
209
  gitignore ||= Clacky::GitignoreParser.new(nil)
@@ -153,7 +226,7 @@ module Clacky
153
226
  if gitignore&.ignored?("#{relative}/") || should_ignore_file?(full, base_path, gitignore)
154
227
  next
155
228
  end
156
- _walk_recursive(full, base_path, gitignore, skipped, &block)
229
+ _walk_recursive(full, base_path, gitignore, skipped, budget, &block)
157
230
  else
158
231
  if !is_config_file?(full) && should_ignore_file?(full, base_path, gitignore)
159
232
  skipped[:ignored] += 1 if skipped
@@ -366,6 +366,24 @@ module Clacky
366
366
  # surprising users with the explicit 25% surcharge).
367
367
  # - We bill reads at 20% (implicit rate) — the conservative side; users on
368
368
  # explicit caching will see real bills slightly *lower* than displayed.
369
+ "qwen3.7-max" => {
370
+ input: { default: 1.20, over_200k: 1.20 },
371
+ output: { default: 6.00, over_200k: 6.00 },
372
+ cache: { write: 1.20, read: 0.24 }
373
+ },
374
+
375
+ "qwen3.7-plus" => {
376
+ input: { default: 0.40, over_200k: 0.40 },
377
+ output: { default: 2.40, over_200k: 2.40 },
378
+ cache: { write: 0.40, read: 0.08 }
379
+ },
380
+
381
+ "qwen3.7-flash" => {
382
+ input: { default: 0.15, over_200k: 0.15 },
383
+ output: { default: 0.90, over_200k: 0.90 },
384
+ cache: { write: 0.15, read: 0.03 }
385
+ },
386
+
369
387
  "qwen3.6-plus" => {
370
388
  input: { default: 0.40, over_200k: 0.40 },
371
389
  output: { default: 2.40, over_200k: 2.40 },
@@ -571,9 +589,16 @@ module Clacky
571
589
  "minimax-m2.7"
572
590
 
573
591
  # Qwen (Alibaba DashScope) — strict anchored match per registered
574
- # model id in providers.rb. qwen3.6-* are the new flagship line;
575
- # qwen-plus-latest is the rolling alias for the latest Qwen-Plus
576
- # release; qwen-vl-* are the multimodal SKUs.
592
+ # model id in providers.rb. qwen3.7-* is the latest flagship line;
593
+ # qwen3.6-* are the previous generation; qwen-plus-latest is the
594
+ # rolling alias for the latest Qwen-Plus release; qwen-vl-* are
595
+ # the multimodal SKUs.
596
+ when /^qwen3\.7-max$/i
597
+ "qwen3.7-max"
598
+ when /^qwen3\.7-plus$/i
599
+ "qwen3.7-plus"
600
+ when /^qwen3\.7-flash$/i
601
+ "qwen3.7-flash"
577
602
  when /^qwen3\.6-plus$/i
578
603
  "qwen3.6-plus"
579
604
  when /^qwen3\.6-max$/i
@@ -21,6 +21,7 @@ module Clacky
21
21
  install_browser.sh
22
22
  install_system_deps.sh
23
23
  install_rails_deps.sh
24
+ wsl_network_doctor.ps1
24
25
  ].freeze
25
26
 
26
27
  # Copy bundled scripts to ~/.clacky/scripts/ if missing or outdated.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.2.2"
4
+ VERSION = "1.2.3"
5
5
  end
@@ -4,6 +4,76 @@
4
4
  # Sets-Vars: (none)
5
5
  # Include via: @include lib/apt.sh
6
6
 
7
+ # Wait until apt/dpkg lock files are no longer held (e.g. by apt-daily on
8
+ # freshly-booted WSL/Ubuntu). Uses flock(1) — the same mechanism apt uses —
9
+ # rather than checking file existence (the lock files are always present;
10
+ # advisory locks live in the kernel, not the filesystem).
11
+ wait_apt_lock() {
12
+ [ "$DISTRO" = "ubuntu" ] || [ "$DISTRO" = "debian" ] || return 0
13
+
14
+ local locks=(
15
+ "/var/lib/dpkg/lock-frontend"
16
+ "/var/lib/dpkg/lock"
17
+ "/var/lib/apt/lists/lock"
18
+ )
19
+ local max_wait="${1:-120}"
20
+ local waited=0
21
+ local announced=false
22
+
23
+ while :; do
24
+ local busy=false
25
+ for f in "${locks[@]}"; do
26
+ [ -e "$f" ] || continue
27
+ if ! sudo flock -n "$f" -c true 2>/dev/null; then
28
+ busy=true
29
+ break
30
+ fi
31
+ done
32
+
33
+ [ "$busy" = false ] && break
34
+
35
+ if [ "$announced" = false ]; then
36
+ print_info "Waiting for system apt/dpkg to finish (up to ${max_wait}s)..."
37
+ announced=true
38
+ fi
39
+
40
+ if [ "$waited" -ge "$max_wait" ]; then
41
+ print_error "apt is still locked after ${max_wait}s."
42
+ print_info "On WSL try: 'wsl --shutdown' from PowerShell, then rerun the installer."
43
+ return 1
44
+ fi
45
+
46
+ sleep 3
47
+ waited=$((waited + 3))
48
+ done
49
+
50
+ [ "$announced" = true ] && print_success "apt lock released"
51
+ return 0
52
+ }
53
+
54
+ # Run an apt-get subcommand with lock-wait + transient-failure retry.
55
+ # Usage: apt_get_run update [-qq]
56
+ # apt_get_run install -y pkg1 pkg2
57
+ apt_get_run() {
58
+ local attempts=3
59
+ local i=1
60
+ while [ "$i" -le "$attempts" ]; do
61
+ wait_apt_lock 120 || return 1
62
+ if sudo apt-get "$@"; then
63
+ return 0
64
+ fi
65
+ local rc=$?
66
+ if [ "$i" -lt "$attempts" ]; then
67
+ print_warning "apt-get $1 failed (exit $rc), retrying ($i/$((attempts-1)))..."
68
+ sleep 5
69
+ else
70
+ print_error "apt-get $1 failed after $attempts attempts."
71
+ return "$rc"
72
+ fi
73
+ i=$((i + 1))
74
+ done
75
+ }
76
+
7
77
  # Configure apt mirror for CN region and run apt-get update.
8
78
  # Guards: only runs on ubuntu/debian ($DISTRO).
9
79
  # Relies on $USE_CN_MIRRORS set by detect_network_region (network.sh).
@@ -51,6 +121,6 @@ EOF
51
121
  print_info "Region: global — using default apt sources"
52
122
  fi
53
123
 
54
- sudo apt-get update -qq
124
+ apt_get_run update -qq || return 1
55
125
  print_success "apt updated"
56
126
  }
@@ -34,7 +34,7 @@ ensure_ruby() {
34
34
 
35
35
  if is_linux_apt; then
36
36
  print_info "Installing Ruby via apt..."
37
- sudo apt-get install -y ruby ruby-dev 2>/dev/null && check_ruby && return 0
37
+ apt_get_run install -y ruby ruby-dev 2>/dev/null && check_ruby && return 0
38
38
  print_warning "apt Ruby install failed or version too old"
39
39
  fi
40
40
 
@@ -31,8 +31,8 @@ install_ruby() {
31
31
  if is_macos; then
32
32
  brew install openssl@3 libyaml gmp
33
33
  elif is_linux_apt; then
34
- sudo apt-get install -y \
35
- rustc libssl-dev libyaml-dev zlib1g-dev libgmp-dev
34
+ apt_get_run install -y \
35
+ rustc libssl-dev libyaml-dev zlib1g-dev libgmp-dev || return 1
36
36
  fi
37
37
 
38
38
  ensure_mise || return 1
@@ -75,8 +75,8 @@ install_postgres() {
75
75
 
76
76
  elif is_linux_apt; then
77
77
  setup_apt_mirror
78
- sudo apt-get install -y postgresql libpq-dev \
79
- libssl-dev libreadline-dev zlib1g-dev libyaml-dev libffi-dev
78
+ apt_get_run install -y postgresql libpq-dev \
79
+ libssl-dev libreadline-dev zlib1g-dev libyaml-dev libffi-dev || return 1
80
80
  sudo systemctl enable --now postgresql || true
81
81
  fi
82
82
 
@@ -67,7 +67,7 @@ ensure_linux_deps() {
67
67
 
68
68
  detect_network_region
69
69
  setup_apt_mirror
70
- sudo apt-get install -y build-essential git curl python3
70
+ apt_get_run install -y build-essential git curl python3 || return 1
71
71
  print_success "Dependencies installed"
72
72
  }
73
73
 
data/scripts/install.sh CHANGED
@@ -271,6 +271,76 @@ detect_network_region() {
271
271
 
272
272
  # ---[ @include lib/apt.sh ]---
273
273
 
274
+ # Wait until apt/dpkg lock files are no longer held (e.g. by apt-daily on
275
+ # freshly-booted WSL/Ubuntu). Uses flock(1) — the same mechanism apt uses —
276
+ # rather than checking file existence (the lock files are always present;
277
+ # advisory locks live in the kernel, not the filesystem).
278
+ wait_apt_lock() {
279
+ [ "$DISTRO" = "ubuntu" ] || [ "$DISTRO" = "debian" ] || return 0
280
+
281
+ local locks=(
282
+ "/var/lib/dpkg/lock-frontend"
283
+ "/var/lib/dpkg/lock"
284
+ "/var/lib/apt/lists/lock"
285
+ )
286
+ local max_wait="${1:-120}"
287
+ local waited=0
288
+ local announced=false
289
+
290
+ while :; do
291
+ local busy=false
292
+ for f in "${locks[@]}"; do
293
+ [ -e "$f" ] || continue
294
+ if ! sudo flock -n "$f" -c true 2>/dev/null; then
295
+ busy=true
296
+ break
297
+ fi
298
+ done
299
+
300
+ [ "$busy" = false ] && break
301
+
302
+ if [ "$announced" = false ]; then
303
+ print_info "Waiting for system apt/dpkg to finish (up to ${max_wait}s)..."
304
+ announced=true
305
+ fi
306
+
307
+ if [ "$waited" -ge "$max_wait" ]; then
308
+ print_error "apt is still locked after ${max_wait}s."
309
+ print_info "On WSL try: 'wsl --shutdown' from PowerShell, then rerun the installer."
310
+ return 1
311
+ fi
312
+
313
+ sleep 3
314
+ waited=$((waited + 3))
315
+ done
316
+
317
+ [ "$announced" = true ] && print_success "apt lock released"
318
+ return 0
319
+ }
320
+
321
+ # Run an apt-get subcommand with lock-wait + transient-failure retry.
322
+ # Usage: apt_get_run update [-qq]
323
+ # apt_get_run install -y pkg1 pkg2
324
+ apt_get_run() {
325
+ local attempts=3
326
+ local i=1
327
+ while [ "$i" -le "$attempts" ]; do
328
+ wait_apt_lock 120 || return 1
329
+ if sudo apt-get "$@"; then
330
+ return 0
331
+ fi
332
+ local rc=$?
333
+ if [ "$i" -lt "$attempts" ]; then
334
+ print_warning "apt-get $1 failed (exit $rc), retrying ($i/$((attempts-1)))..."
335
+ sleep 5
336
+ else
337
+ print_error "apt-get $1 failed after $attempts attempts."
338
+ return "$rc"
339
+ fi
340
+ i=$((i + 1))
341
+ done
342
+ }
343
+
274
344
  # Configure apt mirror for CN region and run apt-get update.
275
345
  # Guards: only runs on ubuntu/debian ($DISTRO).
276
346
  # Relies on $USE_CN_MIRRORS set by detect_network_region (network.sh).
@@ -318,7 +388,7 @@ EOF
318
388
  print_info "Region: global — using default apt sources"
319
389
  fi
320
390
 
321
- sudo apt-get update -qq
391
+ apt_get_run update -qq || return 1
322
392
  print_success "apt updated"
323
393
  }
324
394
 
@@ -427,7 +497,7 @@ ensure_ruby() {
427
497
 
428
498
  if is_linux_apt; then
429
499
  print_info "Installing Ruby via apt..."
430
- sudo apt-get install -y ruby ruby-dev 2>/dev/null && check_ruby && return 0
500
+ apt_get_run install -y ruby ruby-dev 2>/dev/null && check_ruby && return 0
431
501
  print_warning "apt Ruby install failed or version too old"
432
502
  fi
433
503
 
@@ -275,6 +275,76 @@ detect_network_region() {
275
275
 
276
276
  # ---[ @include lib/apt.sh ]---
277
277
 
278
+ # Wait until apt/dpkg lock files are no longer held (e.g. by apt-daily on
279
+ # freshly-booted WSL/Ubuntu). Uses flock(1) — the same mechanism apt uses —
280
+ # rather than checking file existence (the lock files are always present;
281
+ # advisory locks live in the kernel, not the filesystem).
282
+ wait_apt_lock() {
283
+ [ "$DISTRO" = "ubuntu" ] || [ "$DISTRO" = "debian" ] || return 0
284
+
285
+ local locks=(
286
+ "/var/lib/dpkg/lock-frontend"
287
+ "/var/lib/dpkg/lock"
288
+ "/var/lib/apt/lists/lock"
289
+ )
290
+ local max_wait="${1:-120}"
291
+ local waited=0
292
+ local announced=false
293
+
294
+ while :; do
295
+ local busy=false
296
+ for f in "${locks[@]}"; do
297
+ [ -e "$f" ] || continue
298
+ if ! sudo flock -n "$f" -c true 2>/dev/null; then
299
+ busy=true
300
+ break
301
+ fi
302
+ done
303
+
304
+ [ "$busy" = false ] && break
305
+
306
+ if [ "$announced" = false ]; then
307
+ print_info "Waiting for system apt/dpkg to finish (up to ${max_wait}s)..."
308
+ announced=true
309
+ fi
310
+
311
+ if [ "$waited" -ge "$max_wait" ]; then
312
+ print_error "apt is still locked after ${max_wait}s."
313
+ print_info "On WSL try: 'wsl --shutdown' from PowerShell, then rerun the installer."
314
+ return 1
315
+ fi
316
+
317
+ sleep 3
318
+ waited=$((waited + 3))
319
+ done
320
+
321
+ [ "$announced" = true ] && print_success "apt lock released"
322
+ return 0
323
+ }
324
+
325
+ # Run an apt-get subcommand with lock-wait + transient-failure retry.
326
+ # Usage: apt_get_run update [-qq]
327
+ # apt_get_run install -y pkg1 pkg2
328
+ apt_get_run() {
329
+ local attempts=3
330
+ local i=1
331
+ while [ "$i" -le "$attempts" ]; do
332
+ wait_apt_lock 120 || return 1
333
+ if sudo apt-get "$@"; then
334
+ return 0
335
+ fi
336
+ local rc=$?
337
+ if [ "$i" -lt "$attempts" ]; then
338
+ print_warning "apt-get $1 failed (exit $rc), retrying ($i/$((attempts-1)))..."
339
+ sleep 5
340
+ else
341
+ print_error "apt-get $1 failed after $attempts attempts."
342
+ return "$rc"
343
+ fi
344
+ i=$((i + 1))
345
+ done
346
+ }
347
+
278
348
  # Configure apt mirror for CN region and run apt-get update.
279
349
  # Guards: only runs on ubuntu/debian ($DISTRO).
280
350
  # Relies on $USE_CN_MIRRORS set by detect_network_region (network.sh).
@@ -322,7 +392,7 @@ EOF
322
392
  print_info "Region: global — using default apt sources"
323
393
  fi
324
394
 
325
- sudo apt-get update -qq
395
+ apt_get_run update -qq || return 1
326
396
  print_success "apt updated"
327
397
  }
328
398
 
@@ -632,8 +702,8 @@ install_ruby() {
632
702
  if is_macos; then
633
703
  brew install openssl@3 libyaml gmp
634
704
  elif is_linux_apt; then
635
- sudo apt-get install -y \
636
- rustc libssl-dev libyaml-dev zlib1g-dev libgmp-dev
705
+ apt_get_run install -y \
706
+ rustc libssl-dev libyaml-dev zlib1g-dev libgmp-dev || return 1
637
707
  fi
638
708
 
639
709
  ensure_mise || return 1
@@ -676,8 +746,8 @@ install_postgres() {
676
746
 
677
747
  elif is_linux_apt; then
678
748
  setup_apt_mirror
679
- sudo apt-get install -y postgresql libpq-dev \
680
- libssl-dev libreadline-dev zlib1g-dev libyaml-dev libffi-dev
749
+ apt_get_run install -y postgresql libpq-dev \
750
+ libssl-dev libreadline-dev zlib1g-dev libyaml-dev libffi-dev || return 1
681
751
  sudo systemctl enable --now postgresql || true
682
752
  fi
683
753
 
@@ -270,6 +270,76 @@ detect_network_region() {
270
270
 
271
271
  # ---[ @include lib/apt.sh ]---
272
272
 
273
+ # Wait until apt/dpkg lock files are no longer held (e.g. by apt-daily on
274
+ # freshly-booted WSL/Ubuntu). Uses flock(1) — the same mechanism apt uses —
275
+ # rather than checking file existence (the lock files are always present;
276
+ # advisory locks live in the kernel, not the filesystem).
277
+ wait_apt_lock() {
278
+ [ "$DISTRO" = "ubuntu" ] || [ "$DISTRO" = "debian" ] || return 0
279
+
280
+ local locks=(
281
+ "/var/lib/dpkg/lock-frontend"
282
+ "/var/lib/dpkg/lock"
283
+ "/var/lib/apt/lists/lock"
284
+ )
285
+ local max_wait="${1:-120}"
286
+ local waited=0
287
+ local announced=false
288
+
289
+ while :; do
290
+ local busy=false
291
+ for f in "${locks[@]}"; do
292
+ [ -e "$f" ] || continue
293
+ if ! sudo flock -n "$f" -c true 2>/dev/null; then
294
+ busy=true
295
+ break
296
+ fi
297
+ done
298
+
299
+ [ "$busy" = false ] && break
300
+
301
+ if [ "$announced" = false ]; then
302
+ print_info "Waiting for system apt/dpkg to finish (up to ${max_wait}s)..."
303
+ announced=true
304
+ fi
305
+
306
+ if [ "$waited" -ge "$max_wait" ]; then
307
+ print_error "apt is still locked after ${max_wait}s."
308
+ print_info "On WSL try: 'wsl --shutdown' from PowerShell, then rerun the installer."
309
+ return 1
310
+ fi
311
+
312
+ sleep 3
313
+ waited=$((waited + 3))
314
+ done
315
+
316
+ [ "$announced" = true ] && print_success "apt lock released"
317
+ return 0
318
+ }
319
+
320
+ # Run an apt-get subcommand with lock-wait + transient-failure retry.
321
+ # Usage: apt_get_run update [-qq]
322
+ # apt_get_run install -y pkg1 pkg2
323
+ apt_get_run() {
324
+ local attempts=3
325
+ local i=1
326
+ while [ "$i" -le "$attempts" ]; do
327
+ wait_apt_lock 120 || return 1
328
+ if sudo apt-get "$@"; then
329
+ return 0
330
+ fi
331
+ local rc=$?
332
+ if [ "$i" -lt "$attempts" ]; then
333
+ print_warning "apt-get $1 failed (exit $rc), retrying ($i/$((attempts-1)))..."
334
+ sleep 5
335
+ else
336
+ print_error "apt-get $1 failed after $attempts attempts."
337
+ return "$rc"
338
+ fi
339
+ i=$((i + 1))
340
+ done
341
+ }
342
+
273
343
  # Configure apt mirror for CN region and run apt-get update.
274
344
  # Guards: only runs on ubuntu/debian ($DISTRO).
275
345
  # Relies on $USE_CN_MIRRORS set by detect_network_region (network.sh).
@@ -317,7 +387,7 @@ EOF
317
387
  print_info "Region: global — using default apt sources"
318
388
  fi
319
389
 
320
- sudo apt-get update -qq
390
+ apt_get_run update -qq || return 1
321
391
  print_success "apt updated"
322
392
  }
323
393
 
@@ -462,7 +532,7 @@ ensure_linux_deps() {
462
532
 
463
533
  detect_network_region
464
534
  setup_apt_mirror
465
- sudo apt-get install -y build-essential git curl python3
535
+ apt_get_run install -y build-essential git curl python3 || return 1
466
536
  print_success "Dependencies installed"
467
537
  }
468
538
 
@@ -0,0 +1,196 @@
1
+ # wsl_network_doctor.ps1 — diagnose & repair WSL2 mirrored networking for the browser tool.
2
+ #
3
+ # Designed to be invoked from inside WSL via:
4
+ # powershell.exe -NoProfile -ExecutionPolicy Bypass -File <win-path-to-this-script> <subcommand>
5
+ #
6
+ # Subcommands:
7
+ # status Check whether mirrored networking is configured.
8
+ # enable Write networkingMode=mirrored to %USERPROFILE%\.wslconfig.
9
+ # repair Restart Windows Host Network Service (HNS) via UAC elevation.
10
+ #
11
+ # Exit codes (status only):
12
+ # 0 OK — mirrored configured, OR running on WSL1 (no config needed)
13
+ # 10 NEED_ENABLE — mirrored not configured, run `enable`
14
+ # 20 NEED_REPAIR — configured but suspected broken, run `repair`
15
+ # 1 unexpected error
16
+ #
17
+ # `enable` and `repair` exit 0 on success, 1 on failure.
18
+
19
+ param(
20
+ [Parameter(Position = 0)]
21
+ [ValidateSet('status', 'enable', 'repair')]
22
+ [string]$Command
23
+ )
24
+
25
+ $ErrorActionPreference = 'Stop'
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Helpers
29
+ # ---------------------------------------------------------------------------
30
+
31
+ function Get-WslConfigPath {
32
+ return (Join-Path $env:USERPROFILE '.wslconfig')
33
+ }
34
+
35
+ function Test-MirroredConfigured {
36
+ $cfg = Get-WslConfigPath
37
+ if (-not (Test-Path $cfg)) { return $false }
38
+ $content = Get-Content $cfg -Raw -ErrorAction SilentlyContinue
39
+ if ($null -eq $content) { return $false }
40
+ return ($content -match '(?im)^\s*networkingMode\s*=\s*mirrored\s*$')
41
+ }
42
+
43
+ # Returns 1 or 2 if Ubuntu is registered, $null otherwise.
44
+ # Parses `wsl.exe -l -v` output (UTF-16, may contain a star marker on default distro).
45
+ function Get-UbuntuWslVersion {
46
+ try {
47
+ $raw = & wsl.exe -l -v 2>$null
48
+ } catch {
49
+ return $null
50
+ }
51
+ if (-not $raw) { return $null }
52
+
53
+ foreach ($line in $raw) {
54
+ $clean = ($line -replace '\s+', ' ').Trim().TrimStart('*').Trim()
55
+ if ($clean -match '^Ubuntu(?:-[\w\.]+)?\s+\S+\s+(\d+)\s*$') {
56
+ return [int]$matches[1]
57
+ }
58
+ }
59
+ return $null
60
+ }
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Subcommand: status
64
+ # ---------------------------------------------------------------------------
65
+
66
+ function Invoke-Status {
67
+ $wslVer = Get-UbuntuWslVersion
68
+ if ($wslVer -eq 1) {
69
+ Write-Host "OK: Ubuntu is running on WSL1 — shares the Windows network stack directly."
70
+ Write-Host "No mirrored configuration needed. The browser tool can connect to 127.0.0.1 as-is."
71
+ exit 0
72
+ }
73
+
74
+ if (Test-MirroredConfigured) {
75
+ Write-Host "OK: mirrored networking is configured in .wslconfig."
76
+ Write-Host "If the browser tool still cannot connect, run: wsl_network_doctor.ps1 repair"
77
+ exit 0
78
+ }
79
+
80
+ Write-Host "NEED_ENABLE: mirrored networking is not configured."
81
+ Write-Host "Run: wsl_network_doctor.ps1 enable"
82
+ exit 10
83
+ }
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Subcommand: enable
87
+ # ---------------------------------------------------------------------------
88
+
89
+ function Invoke-Enable {
90
+ if (Test-MirroredConfigured) {
91
+ Write-Host "OK: already enabled. No changes needed."
92
+ Write-Host "If the browser tool still cannot connect, run: wsl_network_doctor.ps1 repair"
93
+ exit 0
94
+ }
95
+
96
+ $cfg = Get-WslConfigPath
97
+ Write-Host "Writing networkingMode=mirrored to $cfg ..."
98
+
99
+ if (-not (Test-Path $cfg)) {
100
+ New-Item -ItemType File -Path $cfg -Force | Out-Null
101
+ }
102
+
103
+ $content = Get-Content $cfg -Raw -ErrorAction SilentlyContinue
104
+ if ($null -eq $content) { $content = '' }
105
+
106
+ if ($content -match '(?im)^\s*networkingMode\s*=') {
107
+ $new = [regex]::Replace($content, '(?im)^\s*networkingMode\s*=.*$', 'networkingMode=mirrored')
108
+ Set-Content -Path $cfg -Value $new -NoNewline
109
+ } else {
110
+ if ($content -notmatch '(?im)^\[wsl2\]') {
111
+ if ($content.Length -gt 0 -and -not $content.EndsWith([char]10)) {
112
+ Add-Content -Path $cfg -Value ''
113
+ }
114
+ Add-Content -Path $cfg -Value '[wsl2]'
115
+ }
116
+ Add-Content -Path $cfg -Value 'networkingMode=mirrored'
117
+ }
118
+
119
+ Write-Host "WROTE: .wslconfig updated."
120
+ Write-Host ""
121
+ Write-Host "Next step (cannot be done from inside WSL):"
122
+ Write-Host " 1. Open Windows PowerShell"
123
+ Write-Host " 2. Run: wsl --shutdown"
124
+ Write-Host " 3. Reopen Clacky and run /browser-setup again"
125
+ exit 0
126
+ }
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Subcommand: repair
130
+ # ---------------------------------------------------------------------------
131
+ # Restart Windows Host Network Service (HNS). Requires admin → triggers UAC.
132
+ # Does NOT call `wsl --shutdown` here — the user must run it manually after
133
+ # the elevated window finishes, otherwise our own WSL session would be killed.
134
+
135
+ function Invoke-Repair {
136
+ Write-Host "Repairing Windows Host Network Service (HNS) ..."
137
+ Write-Host ""
138
+ Write-Host "A Windows User Account Control (UAC) dialog will appear."
139
+ Write-Host "Please click 'Yes' to allow the repair script to run."
140
+ Write-Host ""
141
+
142
+ $inner = @'
143
+ try {
144
+ Stop-Service hns -Force -ErrorAction SilentlyContinue
145
+ Start-Service hns -ErrorAction Stop
146
+ Write-Host "HNS restarted successfully."
147
+ } catch {
148
+ Write-Host "Repair failed: $_"
149
+ Start-Sleep 5
150
+ exit 1
151
+ }
152
+ Write-Host ""
153
+ Write-Host "Repair complete. Please run 'wsl --shutdown' in PowerShell, then reopen Clacky."
154
+ Start-Sleep 4
155
+ '@
156
+
157
+ $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($inner))
158
+
159
+ try {
160
+ Start-Process powershell -Verb RunAs -ArgumentList '-NoProfile', '-EncodedCommand', $encoded
161
+ } catch {
162
+ Write-Host "FAILED: could not trigger UAC prompt: $_"
163
+ Write-Host ""
164
+ Write-Host "You can run the repair manually:"
165
+ Write-Host " 1. Open PowerShell as Administrator"
166
+ Write-Host " 2. Run: net stop hns; net start hns"
167
+ Write-Host " 3. Run: wsl --shutdown"
168
+ Write-Host " 4. Reopen Clacky"
169
+ exit 1
170
+ }
171
+
172
+ Write-Host "Repair script launched in an elevated PowerShell window."
173
+ Write-Host ""
174
+ Write-Host "After the elevated window finishes:"
175
+ Write-Host " 1. Run in regular PowerShell: wsl --shutdown"
176
+ Write-Host " 2. Reopen Clacky and run /browser-setup again"
177
+ exit 0
178
+ }
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Dispatch
182
+ # ---------------------------------------------------------------------------
183
+
184
+ switch ($Command) {
185
+ 'status' { Invoke-Status }
186
+ 'enable' { Invoke-Enable }
187
+ 'repair' { Invoke-Repair }
188
+ default {
189
+ Write-Host "Usage: wsl_network_doctor.ps1 {status|enable|repair}"
190
+ Write-Host ""
191
+ Write-Host " status Check whether WSL2 mirrored networking is configured."
192
+ Write-Host " enable Write networkingMode=mirrored to %USERPROFILE%\.wslconfig."
193
+ Write-Host " repair Restart Windows Host Network Service (HNS) via UAC."
194
+ exit 2
195
+ }
196
+ }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.2
4
+ version: 1.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-25 00:00:00.000000000 Z
11
+ date: 2026-05-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -596,6 +596,7 @@ files:
596
596
  - scripts/install_rails_deps.sh
597
597
  - scripts/install_system_deps.sh
598
598
  - scripts/uninstall.sh
599
+ - scripts/wsl_network_doctor.ps1
599
600
  - sig/clacky.rbs
600
601
  homepage: https://github.com/clacky-ai/openclacky
601
602
  licenses: