openclacky 0.9.24 → 0.9.25

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: 5338bcdeaf4b2aafed416365f56467a99a6107911b24df2bfe9d94d757d03528
4
- data.tar.gz: 8e06f6ca6b0d5a0a9c70ab758fc13b2f84d755f7d61e30a9708fdadd4db82309
3
+ metadata.gz: 931a504ff578fcee85cf5d361feeca8b772a0aafe3438c4f27cc7da52573453a
4
+ data.tar.gz: f1cef39e28c531fef2b2b0fb3cf435ffb05729fdeaae12dadb4e338df073ec1e
5
5
  SHA512:
6
- metadata.gz: 7862cd557ea5ba9e88184b314fd88fc820d1f4e0c5a9bbebbfcdee75f33301876aff7df7f13cf07eaa21c7fb47d0efa65e7f051c1641ced6c20d1142e9e52819
7
- data.tar.gz: b33e3cc3331e70328d366a38b67e472c760eb6bbe4b5621532ee4ae60585303d9042c4953f29f31e4256f357765d8aafee2b3e69fa87f31c386f9310be790c46
6
+ metadata.gz: 8b8bc2a7dd702beb4570e40264b63b75f170c26dea56a354b20c9afdd1d97fb0ef1e2a8010457170d23823ed6bc82353bbfd390c9ef8f6137ad9bbf73e15ee17
7
+ data.tar.gz: dc13201eda667f31435309a3664503fd53f1973983e0494f2b29b2ff5c0fca336b9801c265aa18830e1878e0a970237de16ab41833992119d8ea24f2eea66b62
data/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.25] - 2026-04-02
11
+
12
+ ### Added
13
+ - **CSV file upload support**: you can now upload `.csv` files in the Web UI — agent can read and analyse tabular data directly
14
+ - **Browser install tips**: when a browser-dependent command fails, the agent now shows a clear install tip with instructions to set up Chrome/Edge, rather than a cryptic error
15
+ - **Auto-focus on file upload dialog**: the file input field is now auto-focused when the upload dialog opens, improving keyboard UX
16
+ - **Session ID search in Web UI**: you can now search sessions by session ID in addition to session name
17
+
18
+ ### Fixed
19
+ - **WeChat (Weixin) file upload**: fixed a bug where file attachments sent via WeChat were not correctly forwarded to the agent
20
+ - **WeChat without browser**: WeChat channel now works even when no browser tool is configured — falls back gracefully
21
+ - **API message timeout**: fixed a race condition in message compression / session serialisation that could cause API requests to time out mid-conversation
22
+ - **Session chunk replay**: fixed a bug where streaming (chunk-based) messages were incorrectly replayed when restoring a session
23
+
24
+ ### Improved
25
+ - **Shell tool robustness**: `pkill` commands are now scope-limited to prevent accidental process kills; server process cleans up properly when the terminal is closed
26
+ - **Broken pipe handling**: improved error handling in the HTTP server and shell tool to avoid noisy broken-pipe errors on abrupt connection close
27
+
28
+ ### More
29
+ - Updated product-help skill with new session search and CSV upload documentation
30
+ - Updated channel-setup skill with improved WeChat non-browser setup guide
31
+
10
32
  ## [0.9.24] - 2026-04-02
11
33
 
12
34
  ### Added
@@ -128,7 +128,11 @@ module Clacky
128
128
  original_messages = @history.to_a[0..-2] # All except the last (compression instruction)
129
129
 
130
130
  # Archive compressed messages to a chunk MD file before discarding them
131
- chunk_index = @compressed_summaries.size + 1
131
+ # Count existing compressed_summary messages in history to determine the next chunk index.
132
+ # Using @compressed_summaries.size would reset to 0 on process restart and overwrite existing
133
+ # chunk files, creating circular chunk references. Counting from history is always accurate.
134
+ existing_chunk_count = original_messages.count { |m| m[:compressed_summary] }
135
+ chunk_index = existing_chunk_count + 1
132
136
  chunk_path = save_compressed_chunk(
133
137
  original_messages,
134
138
  compression_context[:recent_messages],
@@ -152,6 +152,16 @@ module Clacky
152
152
  # Compressed summary sitting before any user rounds — expand it from chunk md
153
153
  chunk_rounds = parse_chunk_md_to_rounds(msg[:chunk_path])
154
154
  rounds.concat(chunk_rounds)
155
+ # After expanding, treat the last chunk round as the current round so that
156
+ # any orphaned assistant/tool messages that follow in session.json (belonging
157
+ # to the same task whose user message was compressed into the chunk) get
158
+ # appended here instead of being silently discarded.
159
+ current_round = rounds.last
160
+ elsif rounds.last
161
+ # Orphaned non-user message with no current_round yet (e.g. recent_messages
162
+ # after compression started mid-task with no leading user message).
163
+ # Attach to the last known round rather than drop silently.
164
+ rounds.last[:events] << msg
155
165
  end
156
166
  end
157
167
 
@@ -211,10 +221,27 @@ module Clacky
211
221
  #
212
222
  # @param chunk_path [String] Path to the chunk md file
213
223
  # @return [Array<Hash>] rounds array (may be empty if file missing/unreadable)
214
- private def parse_chunk_md_to_rounds(chunk_path)
215
- return [] unless chunk_path && File.exist?(chunk_path.to_s)
224
+ private def parse_chunk_md_to_rounds(chunk_path, visited: Set.new)
225
+ return [] unless chunk_path
226
+
227
+ # 1. Try the stored absolute path first (same machine, normal case).
228
+ # 2. If not found, fall back to basename + SESSIONS_DIR (cross-user / cross-machine).
229
+ resolved = chunk_path.to_s
230
+ unless File.exist?(resolved)
231
+ resolved = File.join(Clacky::SessionManager::SESSIONS_DIR, File.basename(resolved))
232
+ end
233
+
234
+ return [] unless File.exist?(resolved)
235
+
236
+ # Guard against circular chunk references (e.g. chunk-3 → chunk-2 → chunk-1 → chunk-9 → … → chunk-3)
237
+ canonical = File.expand_path(resolved)
238
+ if visited.include?(canonical)
239
+ Clacky::Logger.warn("parse_chunk_md_to_rounds: circular reference detected, skipping #{canonical}")
240
+ return []
241
+ end
242
+ visited = visited.dup.add(canonical)
216
243
 
217
- raw = File.read(chunk_path.to_s, encoding: "utf-8")
244
+ raw = File.read(resolved)
218
245
 
219
246
  # Parse YAML front matter to get archived_at for synthetic timestamps
220
247
  archived_at = nil
@@ -275,7 +302,7 @@ module Clacky
275
302
 
276
303
  # Nested chunk: expand it recursively, prepend before current rounds
277
304
  if sec[:nested_chunk]
278
- nested = parse_chunk_md_to_rounds(sec[:nested_chunk])
305
+ nested = parse_chunk_md_to_rounds(sec[:nested_chunk], visited: visited)
279
306
  rounds = nested + rounds unless nested.empty?
280
307
  # Also render its summary text as an assistant event in current round if any
281
308
  if current_round && !text.empty?
@@ -206,7 +206,7 @@ On success: "✅ WeCom channel configured. WeCom client → Contacts → Smart B
206
206
 
207
207
  Weixin uses a QR code login — no app_id/app_secret needed. The token from the QR scan is saved directly in `channels.yml`.
208
208
 
209
- #### Step 1 — Fetch QR code and open in browser
209
+ #### Step 1 — Fetch QR code
210
210
 
211
211
  Run the script in `--fetch-qr` mode to get the QR URL without blocking:
212
212
 
@@ -221,17 +221,29 @@ Parse the JSON output:
221
221
 
222
222
  If the output contains `"error"`, show it and stop.
223
223
 
224
- Tell the user:
225
- > Opening the WeChat QR code in your browser. Please scan it with WeChat, then confirm in the app.
224
+ #### Step 2 — Show QR code to user (browser or manual fallback)
226
225
 
227
- **Open the QR code page in browser** build a local URL and navigate to it:
226
+ Build the local QR page URL (include current Unix timestamp as `since` to detect new logins only):
227
+ ```
228
+ http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/weixin-qr.html?url=<URL-encoded qrcode_url>&since=<current_unix_timestamp>
229
+ ```
228
230
 
231
+ **Try browser first** — attempt to open the QR page using the browser tool:
229
232
  ```
230
- http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/weixin-qr.html?url=<URL-encoded qrcode_url>
233
+ browser(action="navigate", url="<qr_page_url>")
231
234
  ```
232
235
 
233
- Use the browser tool to open this URL. The page renders a proper scannable QR code image using qrcode.js.
234
- Do NOT open the raw `qrcode_url` directly that page shows "请使用微信扫码打开" with no actual QR image.
236
+ **If browser succeeds:** Tell the user:
237
+ > I've opened the WeChat QR code in your browser. Please scan it with WeChat, then confirm in the app.
238
+
239
+ **If browser fails (not configured or unavailable):** Fall back to manual — tell the user:
240
+ > Please open the following link in your browser to scan the WeChat QR code:
241
+ >
242
+ > `http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/weixin-qr.html?url=<URL-encoded qrcode_url>`
243
+ >
244
+ > Scan the QR code with WeChat, confirm in the app, then reply "done".
245
+
246
+ The page renders a proper scannable QR code image. Do NOT open the raw `qrcode_url` directly — that page shows "请使用微信扫码打开" with no actual QR image.
235
247
 
236
248
  #### Step 3 — Wait for scan and save credentials
237
249
 
@@ -243,7 +255,11 @@ ruby "SKILL_DIR/weixin_setup.rb" --qrcode-id "$QRCODE_ID"
243
255
 
244
256
  Where `$QRCODE_ID` is the `qrcode_id` from Step 2's JSON output.
245
257
 
246
- This command blocks until the user scans and confirms in WeChat (up to 5 minutes), then automatically saves the token via `POST /api/channels/weixin`.
258
+ Run this command with `timeout: 60`. If it doesn't succeed, **retry up to 3 times with the same `$QRCODE_ID`** — the QR code stays valid for 5 minutes. Only stop retrying if:
259
+ - Exit code is 0 → success
260
+ - Output contains "expired" → QR expired, offer to restart from Step 1
261
+ - Output contains "timed out" → offer to restart from Step 1
262
+ - 3 retries exhausted → show error and offer to restart from Step 1
247
263
 
248
264
  Tell the user while waiting:
249
265
  > Waiting for you to scan the QR code and confirm in WeChat... (this may take a moment)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: product-help
3
- description: 'Use this skill when the user asks about my own features, configuration, or usage — installation, skills, Web UI, CLI, API config, memory, sessions, encryption, white-label, publishing, pricing, or troubleshooting. Do NOT trigger for general coding tasks unrelated to me.'
3
+ description: 'Use this skill when the user asks about my own features, configuration, or usage — installation, skills, Web UI, CLI, API config, memory, sessions, encryption, white-label, publishing, pricing, troubleshooting, or restarting the server. Do NOT trigger for general coding tasks unrelated to me.'
4
4
  fork_agent: true
5
5
  user-invocable: false
6
6
  auto_summarize: true
@@ -90,3 +90,16 @@ web_fetch(url: "<URL>", max_length: 5000)
90
90
  - If the fetched page doesn't answer the question, try the next most relevant URL (max 2 fetches)
91
91
  - If still no answer, tell the user: "请访问 https://www.openclacky.com/docs 查看完整文档"
92
92
  - Keep answers concise — extract what's relevant, don't paste the whole page
93
+
94
+ ## Restarting the server
95
+
96
+ If the user asks to restart the clacky/openclacky server (e.g. "重启", "restart", "请重启openclacky"):
97
+
98
+ **Do NOT fetch any docs.** Just return this answer directly:
99
+
100
+ > To restart the server gracefully (hot restart, zero downtime):
101
+ > ```
102
+ > kill -USR1 $CLACKY_MASTER_PID
103
+ > ```
104
+ > This sends USR1 to the Master process, which spawns a new Worker and gracefully stops the old one.
105
+ > The `$CLACKY_MASTER_PID` environment variable is already set in the current session.
@@ -260,14 +260,17 @@ module Clacky
260
260
  # Extract and materialize file attachments from an inbound item_list.
261
261
  #
262
262
  # Images are downloaded from CDN and converted to data_url so the agent's
263
- # vision pipeline (partition_files → resolve_vision_images) picks them up
264
- # correctly. Other file types are returned with cdn_media metadata only
265
- # (download-on-demand is not yet implemented for non-image types).
263
+ # vision pipeline (partition_files → resolve_vision_images) picks them up.
264
+ # Files (PDF, DOCX, etc.) are downloaded to clacky-uploads so the agent's
265
+ # file processing pipeline (process_path) can parse them.
266
+ # Voice/video are kept as cdn_media metadata only (no local download).
266
267
  #
267
- # Returns Array of Hashes. Image entries include:
268
+ # Returns Array of Hashes. Image entries:
268
269
  # { type: :image, name: String, mime_type: String, data_url: String }
269
- # Other entries include:
270
- # { type: :file/:voice/:video, name: String, cdn_media: Hash }
270
+ # File entries (downloaded):
271
+ # { type: :file, name: String, path: String }
272
+ # Voice/video entries:
273
+ # { type: :voice/:video, name: String, cdn_media: Hash }
271
274
  def extract_files(item_list)
272
275
  files = []
273
276
  item_list.each do |item|
@@ -311,16 +314,46 @@ module Clacky
311
314
  name: "voice.amr",
312
315
  cdn_media: v["media"]
313
316
  }
314
- when 4 # FILE
317
+ when 4 # FILE — download to disk so agent can parse it
315
318
  fi = item["file_item"]
316
319
  next unless fi
317
- files << {
318
- type: :file,
319
- name: fi["file_name"],
320
- md5: fi["md5"],
321
- len: fi["len"],
322
- cdn_media: fi["media"]
323
- }
320
+ cdn_media = fi["media"]
321
+ file_name = fi["file_name"].to_s
322
+ file_name = "attachment" if file_name.empty?
323
+ file_md5 = fi["md5"].to_s
324
+ file_len = fi["len"].to_s
325
+
326
+ if cdn_media
327
+ begin
328
+ raw_bytes = @api_client.download_media(cdn_media, ApiClient::MEDIA_TYPE_FILE)
329
+ saved = Clacky::Utils::FileProcessor.save(body: raw_bytes, filename: file_name)
330
+ Clacky::Logger.info("[WeixinAdapter] file downloaded to #{saved[:path]} (#{raw_bytes.bytesize} bytes)")
331
+ files << {
332
+ type: :file,
333
+ name: saved[:name],
334
+ path: saved[:path],
335
+ md5: file_md5.empty? ? nil : file_md5,
336
+ len: file_len.empty? ? nil : file_len
337
+ }
338
+ rescue => e
339
+ Clacky::Logger.warn("[WeixinAdapter] Failed to download file #{file_name}: #{e.message}\n#{e.backtrace.first(3).join("\n")}")
340
+ # Fall back to metadata-only so the agent at least knows a file was attached
341
+ files << {
342
+ type: :file,
343
+ name: file_name,
344
+ cdn_media: cdn_media,
345
+ md5: file_md5.empty? ? nil : file_md5,
346
+ len: file_len.empty? ? nil : file_len
347
+ }
348
+ end
349
+ else
350
+ files << {
351
+ type: :file,
352
+ name: file_name,
353
+ md5: file_md5.empty? ? nil : file_md5,
354
+ len: file_len.empty? ? nil : file_len
355
+ }
356
+ end
324
357
  when 5 # VIDEO
325
358
  vi = item["video_item"]
326
359
  next unless vi
@@ -9,6 +9,7 @@ require "tmpdir"
9
9
  require "uri"
10
10
  require "open3"
11
11
  require "securerandom"
12
+ require "timeout"
12
13
  require_relative "session_registry"
13
14
  require_relative "web_ui_controller"
14
15
  require_relative "scheduler"
@@ -317,12 +318,32 @@ module Clacky
317
318
  path = req.path
318
319
  method = req.request_method
319
320
 
320
- # WebSocket upgrade
321
+
322
+
323
+ # WebSocket upgrade — no timeout applied (long-lived connection)
321
324
  if websocket_upgrade?(req)
322
325
  handle_websocket(req, res)
323
326
  return
324
327
  end
325
328
 
329
+ # Wrap all REST handlers in a timeout so a hung handler (e.g. infinite
330
+ # recursion in chunk parsing) returns a proper 503 instead of an empty 200.
331
+ timeout_sec = 10
332
+ Timeout.timeout(timeout_sec) do
333
+ _dispatch_rest(req, res)
334
+ end
335
+ rescue Timeout::Error
336
+ Clacky::Logger.warn("[HTTP 503] #{method} #{path} timed out after #{timeout_sec}s")
337
+ json_response(res, 503, { error: "Request timed out" })
338
+ rescue => e
339
+ Clacky::Logger.warn("[HTTP 500] #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
340
+ json_response(res, 500, { error: e.message })
341
+ end
342
+
343
+ def _dispatch_rest(req, res)
344
+ path = req.path
345
+ method = req.request_method
346
+
326
347
  case [method, path]
327
348
  when ["GET", "/api/sessions"] then api_list_sessions(req, res)
328
349
  when ["POST", "/api/sessions"] then api_create_session(req, res)
@@ -395,9 +416,6 @@ module Clacky
395
416
  not_found(res)
396
417
  end
397
418
  end
398
- rescue => e
399
- $stderr.puts "[HTTP 500] #{e.class}: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
400
- json_response(res, 500, { error: e.message })
401
419
  end
402
420
 
403
421
  # ── REST API ──────────────────────────────────────────────────────────────
@@ -1102,6 +1120,9 @@ module Clacky
1102
1120
  fields = body.transform_keys(&:to_sym).reject { |k, _| k == :platform }
1103
1121
  fields = fields.transform_values { |v| v.is_a?(String) ? v.strip : v }
1104
1122
 
1123
+ # Record when the token was last updated so clients can detect re-login
1124
+ fields[:token_updated_at] = Time.now.to_i if platform == :weixin && fields.key?(:token)
1125
+
1105
1126
  # Validate credentials against live API before persisting.
1106
1127
  # Merge with existing config so partial updates (e.g. allowed_users only) still validate correctly.
1107
1128
  klass = Clacky::Channel::Adapters.find(platform)
@@ -1177,10 +1198,10 @@ module Clacky
1177
1198
  }
1178
1199
  when :weixin
1179
1200
  {
1180
- base_url: raw["base_url"] || Clacky::Channel::Adapters::Weixin::ApiClient::DEFAULT_BASE_URL,
1181
- allowed_users: raw["allowed_users"] || [],
1182
- # Indicate whether a token is present (never expose the token itself)
1183
- has_token: !raw["token"].to_s.strip.empty?
1201
+ base_url: raw["base_url"] || Clacky::Channel::Adapters::Weixin::ApiClient::DEFAULT_BASE_URL,
1202
+ allowed_users: raw["allowed_users"] || [],
1203
+ has_token: !raw["token"].to_s.strip.empty?,
1204
+ token_updated_at: raw["token_updated_at"] # Unix timestamp, nil if never set
1184
1205
  }
1185
1206
  else
1186
1207
  {}
@@ -1624,6 +1645,7 @@ module Clacky
1624
1645
  # Returns a list of UI events (same format as WS events) for the frontend to render.
1625
1646
  def api_session_messages(session_id, req, res)
1626
1647
  unless @registry.ensure(session_id)
1648
+ Clacky::Logger.warn("[messages] registry.ensure failed", session_id: session_id)
1627
1649
  return json_response(res, 404, { error: "Session not found" })
1628
1650
  end
1629
1651
 
@@ -1636,6 +1658,7 @@ module Clacky
1636
1658
  @registry.with_session(session_id) { |s| agent = s[:agent] }
1637
1659
 
1638
1660
  unless agent
1661
+ Clacky::Logger.warn("[messages] agent is nil", session_id: session_id)
1639
1662
  return json_response(res, 200, { events: [], has_more: false })
1640
1663
  end
1641
1664
 
@@ -1691,7 +1714,7 @@ module Clacky
1691
1714
  handshake = WebSocket::Handshake::Server.new
1692
1715
  handshake << build_handshake_request(req)
1693
1716
  unless handshake.finished? && handshake.valid?
1694
- $stderr.puts "WebSocket handshake invalid"
1717
+ Clacky::Logger.warn("WebSocket handshake invalid")
1695
1718
  return
1696
1719
  end
1697
1720
 
@@ -1730,8 +1753,8 @@ module Clacky
1730
1753
  end
1731
1754
  end
1732
1755
  end
1733
- rescue IOError, Errno::ECONNRESET, Errno::EPIPE
1734
- # Client disconnected
1756
+ rescue IOError, Errno::ECONNRESET, Errno::EPIPE, Errno::EBADF
1757
+ # Client disconnected or socket became invalid
1735
1758
  ensure
1736
1759
  on_ws_close(conn)
1737
1760
  socket.close rescue nil
@@ -1741,7 +1764,7 @@ module Clacky
1741
1764
  res.instance_variable_set(:@header, {})
1742
1765
  res.status = -1
1743
1766
  rescue => e
1744
- $stderr.puts "WebSocket handler error: #{e.class}: #{e.message}"
1767
+ Clacky::Logger.error("WebSocket handler error: #{e.class}: #{e.message}")
1745
1768
  end
1746
1769
 
1747
1770
  # Build a raw HTTP request string from WEBrick request for WebSocket::Handshake::Server
@@ -1946,15 +1969,28 @@ module Clacky
1946
1969
  end
1947
1970
 
1948
1971
  # Broadcast an event to all clients subscribed to a session.
1972
+ # Dead connections (broken pipe / closed socket) are removed automatically.
1949
1973
  def broadcast(session_id, event)
1950
1974
  clients = @ws_mutex.synchronize { (@ws_clients[session_id] || []).dup }
1951
- clients.each { |conn| conn.send_json(event) rescue nil }
1975
+ dead = clients.reject { |conn| conn.send_json(event) }
1976
+ return if dead.empty?
1977
+
1978
+ @ws_mutex.synchronize do
1979
+ (@ws_clients[session_id] || []).reject! { |conn| dead.include?(conn) }
1980
+ end
1952
1981
  end
1953
1982
 
1954
1983
  # Broadcast an event to every connected client (regardless of session subscription).
1984
+ # Dead connections are removed automatically.
1955
1985
  def broadcast_all(event)
1956
1986
  clients = @ws_mutex.synchronize { @all_ws_conns.dup }
1957
- clients.each { |conn| conn.send_json(event) rescue nil }
1987
+ dead = clients.reject { |conn| conn.send_json(event) }
1988
+ return if dead.empty?
1989
+
1990
+ @ws_mutex.synchronize do
1991
+ @all_ws_conns.reject! { |conn| dead.include?(conn) }
1992
+ @ws_clients.each_value { |list| list.reject! { |conn| dead.include?(conn) } }
1993
+ end
1958
1994
  end
1959
1995
 
1960
1996
  # Broadcast a session_update event to all clients so they can patch their
@@ -2163,16 +2199,29 @@ module Clacky
2163
2199
  @socket = socket
2164
2200
  @version = version
2165
2201
  @send_mutex = Mutex.new
2202
+ @closed = false
2166
2203
  end
2167
2204
 
2205
+ # Returns true if the underlying socket has been detected as dead.
2206
+ def closed?
2207
+ @closed
2208
+ end
2209
+
2210
+ # Send a JSON-serializable object over the WebSocket.
2211
+ # Returns true on success, false if the connection is dead.
2168
2212
  def send_json(data)
2169
2213
  send_raw(:text, JSON.generate(data))
2170
2214
  rescue => e
2171
- $stderr.puts "WS send error: #{e.message}"
2215
+ Clacky::Logger.debug("WS send error (connection dead): #{e.message}")
2216
+ false
2172
2217
  end
2173
2218
 
2219
+ # Send a raw WebSocket frame.
2220
+ # Returns true on success, false on broken/closed socket.
2174
2221
  def send_raw(type, data)
2175
2222
  @send_mutex.synchronize do
2223
+ return false if @closed
2224
+
2176
2225
  outgoing = WebSocket::Frame::Outgoing::Server.new(
2177
2226
  version: @version,
2178
2227
  data: data,
@@ -2180,8 +2229,15 @@ module Clacky
2180
2229
  )
2181
2230
  @socket.write(outgoing.to_s)
2182
2231
  end
2232
+ true
2233
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError, Errno::EBADF => e
2234
+ @closed = true
2235
+ Clacky::Logger.debug("WS send_raw error (client disconnected): #{e.message}")
2236
+ false
2183
2237
  rescue => e
2184
- $stderr.puts "WS send_raw error: #{e.message}"
2238
+ @closed = true
2239
+ Clacky::Logger.debug("WS send_raw unexpected error: #{e.message}")
2240
+ false
2185
2241
  end
2186
2242
  end
2187
2243
  end
@@ -58,6 +58,7 @@ module Clacky
58
58
  Signal.trap("USR1") { @restart_requested = true }
59
59
  Signal.trap("TERM") { @shutdown_requested = true }
60
60
  Signal.trap("INT") { @shutdown_requested = true }
61
+ Signal.trap("HUP") { @shutdown_requested = true }
61
62
 
62
63
  # 4. Spawn first worker
63
64
  @worker_pid = spawn_worker
@@ -64,15 +64,17 @@ module Clacky
64
64
  session_data = nil
65
65
 
66
66
  @mutex.synchronize do
67
- # Already fully ready fast path.
68
- return true if @sessions.key?(session_id)
69
-
70
- # Another thread is currently restoring this session — wait for it.
67
+ # Another thread is currently restoring this session (including the case where
68
+ # @registry.create was already called but with_session agent-set is not yet done)
69
+ # wait for it to finish so callers never see agent=nil.
71
70
  if @restoring[session_id]
72
71
  @restore_cond.wait(@mutex) until !@restoring[session_id]
73
72
  return @sessions.key?(session_id)
74
73
  end
75
74
 
75
+ # Already fully ready (not being restored) — fast path.
76
+ return true if @sessions.key?(session_id)
77
+
76
78
  return false unless @session_manager && @session_restorer
77
79
 
78
80
  session_data = @session_manager.load(session_id)
@@ -174,10 +176,13 @@ module Clacky
174
176
  # ── date filter (YYYY-MM-DD, matches created_at prefix) ──────────────
175
177
  all = all.select { |s| s[:created_at].to_s.start_with?(date) } if date
176
178
 
177
- # ── name search ──────────────────────────────────────────────────────
179
+ # ── name / id search ─────────────────────────────────────────────────
178
180
  if q && !q.empty?
179
181
  q_down = q.downcase
180
- all = all.select { |s| (s[:name] || "").downcase.include?(q_down) }
182
+ all = all.select { |s|
183
+ (s[:name] || "").downcase.include?(q_down) ||
184
+ (s[:session_id] || "").downcase.include?(q_down)
185
+ }
181
186
  end
182
187
 
183
188
  all = all.select { |s| (s[:created_at] || "") < before } if before
@@ -341,6 +341,10 @@ module Clacky
341
341
  @safe_check_command = Clacky::Utils::Encoding.safe_check(command)
342
342
 
343
343
  case @safe_check_command
344
+ when /pkill.*clacky|killall.*clacky|kill\s+.*\bclacky\b/i
345
+ raise SecurityError, "Killing the clacky server process is not allowed. To restart, use: kill -USR1 $CLACKY_MASTER_PID"
346
+ when /clacky\s+server/
347
+ raise SecurityError, "Managing the clacky server from within a session is not allowed. To restart, use: kill -USR1 $CLACKY_MASTER_PID"
344
348
  when /^rm\s+/
345
349
  replace_rm_command(command)
346
350
  when /^chmod\s+x/
@@ -63,7 +63,7 @@ module Clacky
63
63
  [/[Pp]assword\s*:\s*$|Enter password|enter password/, 'password'],
64
64
  [/^\s*>>>\s*$|^\s*>>?\s*$|^irb\(.*\):\d+:\d+[>*]\s*$|^\>\s*$/, 'repl'],
65
65
  [/^\s*:\s*$|\(END\)|--More--|Press .* to continue|lines \d+-\d+/, 'pager'],
66
- [/Are you sure|Continue\?|Proceed\?|Confirm|Overwrite/i, 'question'],
66
+ [/Are you sure|Continue\?|Proceed\?|\bConfirm\b|\bConfirm\?|Overwrite/i, 'question'],
67
67
  [/Enter\s+\w+:|Input\s+\w+:|Please enter|please provide/i, 'input'],
68
68
  [/Select an option|Choose|Which one|select one/i, 'selection']
69
69
  ].freeze
@@ -251,45 +251,83 @@ module Clacky
251
251
  end
252
252
  end
253
253
 
254
- # Wrap command in a login shell and explicitly source the user's interactive
255
- # rc file so that PATH customisations from nvm, rbenv, brew, etc. are available.
254
+ # Wrap command in a login shell when shell config files have changed since
255
+ # the last check. This ensures PATH updates (nvm, rbenv, mise, brew, etc.)
256
+ # are picked up without paying the ~1s startup cost on every command.
256
257
  #
257
- # We use -l (login) instead of -i (interactive) to avoid SIGTTIN: when the
258
- # process runs in a new process group (pgroup: 0) it is not the terminal's
259
- # foreground group, so an interactive shell trying to acquire /dev/tty gets
260
- # stopped by the kernel immediately.
258
+ # Strategy:
259
+ # - On first call: snapshot MD5 hashes of all shell config files in memory.
260
+ # - On subsequent calls: re-hash and compare. If any file changed, use
261
+ # `shell -l -i -c ...` once to get the fresh environment, then update snapshot.
262
+ # - If nothing changed: run command directly (zero overhead).
261
263
  #
262
- # -l loads ~/.zprofile / ~/.bash_profile which is enough for most PATH setup.
263
- # We additionally source the interactive rc file (~/.zshrc or ~/.bashrc) so
264
- # aliases, functions, and tool shims (rbenv init, nvm, etc.) are also present.
264
+ # We avoid -i (interactive) in normal mode because it causes SIGTTIN when the
265
+ # process is not in the terminal's foreground group (pgroup: 0).
266
+ # When configs have changed we do use -l -i so that all shell init hooks
267
+ # (nvm, rbenv init, mise activate, etc.) run in the correct order.
265
268
  def wrap_with_shell(command)
266
- # shell = ENV['SHELL'].to_s
267
- # shell = '/bin/bash' if shell.empty?
269
+ shell = current_shell
268
270
 
269
- # rc_source = rc_source_snippet(shell)
270
- # full_command = rc_source ? "#{rc_source} #{command}" : command
271
+ if shell_configs_changed?(shell)
272
+ "#{shell} -l -i -c #{Shellwords.escape(command)}"
273
+ else
274
+ command
275
+ end
276
+ end
277
+
278
+ # Detect the user's current shell binary.
279
+ private def current_shell
280
+ shell = ENV['SHELL'].to_s
281
+ shell.empty? ? '/bin/bash' : shell
282
+ end
283
+
284
+ # Returns true (and updates the in-memory snapshot) when any shell config
285
+ # file has changed since the last call. Returns false on the very first call
286
+ # so the initial snapshot is established without triggering a reload.
287
+ #
288
+ # Config files checked per shell:
289
+ # zsh — ~/.zshrc, ~/.zprofile, ~/.zshenv
290
+ # bash — ~/.bashrc, ~/.bash_profile, ~/.profile
291
+ # fish — ~/.config/fish/config.fish
292
+ # (all shells also check ~/.profile as a fallback)
293
+ private def shell_configs_changed?(shell)
294
+ require "digest"
295
+
296
+ current = shell_config_hashes(shell)
297
+
298
+ if @shell_config_hashes.nil?
299
+ # First call: establish baseline, do NOT trigger reload
300
+ @shell_config_hashes = current
301
+ return false
302
+ end
303
+
304
+ if current != @shell_config_hashes
305
+ @shell_config_hashes = current
306
+ return true
307
+ end
271
308
 
272
- # "#{shell} -l -c #{Shellwords.escape(full_command)}"
273
- return command
309
+ false
274
310
  end
275
311
 
276
- # Returns a shell snippet that sources the user's interactive rc file,
277
- # suppressing all errors so missing files never break command execution.
278
- private def rc_source_snippet(shell)
312
+ # Returns a hash of { filepath => md5_hex } for existing config files.
313
+ private def shell_config_hashes(shell)
279
314
  shell_name = File.basename(shell)
280
315
  home = ENV['HOME'].to_s
281
316
 
282
- rc_file = case shell_name
283
- when 'zsh' then File.join(home, '.zshrc')
284
- when 'bash' then File.join(home, '.bashrc')
285
- when 'fish' then File.join(home, '.config', 'fish', 'config.fish')
317
+ files = case shell_name
318
+ when 'zsh'
319
+ %w[.zshrc .zprofile .zshenv].map { |f| File.join(home, f) }
320
+ when 'bash'
321
+ %w[.bashrc .bash_profile .profile].map { |f| File.join(home, f) }
322
+ when 'fish'
323
+ [File.join(home, '.config', 'fish', 'config.fish')]
324
+ else
325
+ [File.join(home, '.profile')]
286
326
  end
287
327
 
288
- return nil unless rc_file
289
-
290
- # Source silently: suppress stderr and ignore exit code so that
291
- # errors in the user's rc file don't prevent the command from running.
292
- "source #{Shellwords.escape(rc_file)} 2>/dev/null;"
328
+ files
329
+ .select { |f| File.exist?(f) }
330
+ .to_h { |f| [f, Digest::MD5.file(f).hexdigest] }
293
331
  end
294
332
 
295
333
  def determine_timeouts(command, soft_timeout, hard_timeout)
@@ -62,7 +62,8 @@ module Clacky
62
62
  ".pdf" => :pdf,
63
63
  ".zip" => :zip, ".gz" => :zip, ".tar" => :zip, ".rar" => :zip, ".7z" => :zip,
64
64
  ".png" => :image, ".jpg" => :image, ".jpeg" => :image,
65
- ".gif" => :image, ".webp" => :image
65
+ ".gif" => :image, ".webp" => :image,
66
+ ".csv" => :csv
66
67
  }.freeze
67
68
 
68
69
  # FileRef: result of process / process_path.
@@ -109,6 +110,17 @@ module Clacky
109
110
  when ".png", ".jpg", ".jpeg", ".gif", ".webp"
110
111
  FileRef.new(name: name, type: :image, original_path: path)
111
112
 
113
+ when ".csv"
114
+ # CSV is plain text — read directly, no external parser needed.
115
+ # Try UTF-8 first, then GBK (common in Chinese-origin CSV), then binary with replacement.
116
+ begin
117
+ text = read_text_with_encoding_fallback(path)
118
+ preview_path = save_preview(text, path)
119
+ FileRef.new(name: name, type: :csv, original_path: path, preview_path: preview_path)
120
+ rescue => e
121
+ FileRef.new(name: name, type: :csv, original_path: path, parse_error: e.message)
122
+ end
123
+
112
124
  else
113
125
  result = Utils::ParserManager.parse(path)
114
126
  if result[:success]
@@ -225,7 +237,27 @@ module Clacky
225
237
  base.empty? ? 'upload' : base
226
238
  end
227
239
 
228
- private_class_method :parse_zip_listing, :save_preview, :sanitize_filename
240
+ # Read a text file with automatic encoding detection.
241
+ # Tries UTF-8, then GBK (common for Chinese-origin CSV/text files), then
242
+ # falls back to binary read with invalid byte replacement.
243
+ def self.read_text_with_encoding_fallback(path)
244
+ # Try UTF-8 first (most common, fastest path)
245
+ raw = File.binread(path)
246
+ utf8 = raw.dup.force_encoding("UTF-8")
247
+ return utf8.encode("UTF-8") if utf8.valid_encoding?
248
+
249
+ # Try GBK (GB2312 superset — common in Chinese Windows/Excel exports)
250
+ begin
251
+ return raw.encode("UTF-8", "GBK", invalid: :replace, undef: :replace, replace: "?")
252
+ rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
253
+ # fall through
254
+ end
255
+
256
+ # Last resort: binary read with replacement characters
257
+ raw.encode("UTF-8", "binary", invalid: :replace, undef: :replace, replace: "?")
258
+ end
259
+
260
+ private_class_method :parse_zip_listing, :save_preview, :sanitize_filename, :read_text_with_encoding_fallback
229
261
  end
230
262
  end
231
263
  end
@@ -14,7 +14,7 @@ module Clacky
14
14
  # via gem version stamp in ~/.clacky/scripts/.version).
15
15
  module ScriptsManager
16
16
  SCRIPTS_DIR = File.expand_path("~/.clacky/scripts").freeze
17
- DEFAULT_SCRIPTS_DIR = File.expand_path("../../../../scripts", __dir__).freeze
17
+ DEFAULT_SCRIPTS_DIR = File.expand_path("../../../scripts", __dir__).freeze
18
18
  VERSION_FILE = File.join(SCRIPTS_DIR, ".version").freeze
19
19
 
20
20
  SCRIPTS = %w[
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.9.24"
4
+ VERSION = "0.9.25"
5
5
  end
@@ -498,6 +498,8 @@ const ACCEPTED_DOC_TYPES = [
498
498
  "application/vnd.ms-excel", // .xls
499
499
  "application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx
500
500
  "application/vnd.ms-powerpoint", // .ppt
501
+ "text/csv", // .csv
502
+ "application/csv", // .csv (some browsers)
501
503
  ];
502
504
 
503
505
  function _docTypeIcon(mimeType) {
@@ -506,6 +508,7 @@ function _docTypeIcon(mimeType) {
506
508
  if (mimeType.includes("wordprocessingml") || mimeType === "application/msword") return "📝";
507
509
  if (mimeType.includes("spreadsheetml") || mimeType === "application/vnd.ms-excel") return "📊";
508
510
  if (mimeType.includes("presentationml") || mimeType === "application/vnd.ms-powerpoint") return "📋";
511
+ if (mimeType === "text/csv" || mimeType === "application/csv") return "📊";
509
512
  return "📎";
510
513
  }
511
514
 
@@ -586,6 +589,7 @@ function _addGenericFile(file) {
586
589
  mime_type: file.type
587
590
  });
588
591
  _renderAttachmentPreviews();
592
+ setTimeout(() => $("user-input").focus(), 100);
589
593
  })
590
594
  .catch(err => alert(`Upload error: ${err.message}`));
591
595
  }
@@ -256,7 +256,7 @@
256
256
  <div id="image-preview-strip" style="display:none"></div>
257
257
  <div id="input-bar">
258
258
  <!-- Hidden file picker -->
259
- <input type="file" id="image-file-input" accept="image/png,image/jpeg,image/gif,image/webp,application/pdf,application/zip,application/x-zip-compressed,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/msword,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.ms-powerpoint" multiple style="display:none">
259
+ <input type="file" id="image-file-input" accept="image/png,image/jpeg,image/gif,image/webp,application/pdf,application/zip,application/x-zip-compressed,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/msword,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.ms-powerpoint,text/csv,application/csv,.csv" multiple style="display:none">
260
260
  <button id="btn-attach" title="Attach image (or drag & drop / Ctrl+V)">
261
261
  <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
262
262
  <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66L9.41 17.41a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
@@ -794,7 +794,6 @@ const Sessions = (() => {
794
794
  // ── Apply client-side filter (mirrors server params for instant feedback) ─
795
795
  const { q, date, type } = _filter;
796
796
  let visible = [..._sessions].sort(byTime);
797
- if (q) visible = visible.filter(s => (s.name || "").toLowerCase().includes(q.toLowerCase()));
798
797
  if (date) visible = visible.filter(s => (s.created_at || "").startsWith(date));
799
798
  if (type) {
800
799
  visible = type === "coding"
@@ -66,6 +66,57 @@
66
66
  background: #fff5f5;
67
67
  border-radius: 8px;
68
68
  }
69
+
70
+ /* Success overlay */
71
+ #success-overlay {
72
+ display: none;
73
+ position: fixed;
74
+ inset: 0;
75
+ background: rgba(255,255,255,0.92);
76
+ align-items: center;
77
+ justify-content: center;
78
+ flex-direction: column;
79
+ gap: 16px;
80
+ z-index: 100;
81
+ }
82
+ #success-overlay.show {
83
+ display: flex;
84
+ }
85
+ .success-icon {
86
+ width: 72px;
87
+ height: 72px;
88
+ background: linear-gradient(135deg, #2dc100, #1aad19);
89
+ border-radius: 50%;
90
+ display: flex;
91
+ align-items: center;
92
+ justify-content: center;
93
+ font-size: 36px;
94
+ animation: pop 0.3s ease-out;
95
+ }
96
+ .success-title {
97
+ font-size: 20px;
98
+ font-weight: 700;
99
+ color: #111;
100
+ }
101
+ .success-sub {
102
+ font-size: 14px;
103
+ color: #888;
104
+ }
105
+ @keyframes pop {
106
+ 0% { transform: scale(0.5); opacity: 0; }
107
+ 80% { transform: scale(1.1); }
108
+ 100% { transform: scale(1); opacity: 1; }
109
+ }
110
+
111
+ /* Scanned state */
112
+ .scanned-hint {
113
+ display: none;
114
+ font-size: 13px;
115
+ color: #1aad19;
116
+ font-weight: 500;
117
+ margin-top: 8px;
118
+ }
119
+ .scanned-hint.show { display: block; }
69
120
  </style>
70
121
  </head>
71
122
  <body>
@@ -75,13 +126,25 @@
75
126
  <p>使用微信扫描下方二维码<br>在手机上点击「确认登录」</p>
76
127
  <div id="qrcode"></div>
77
128
  <div class="hint">二维码有效期 5 分钟</div>
129
+ <div class="scanned-hint" id="scanned-hint">✅ 已扫码,请在手机上确认…</div>
130
+ </div>
131
+
132
+ <!-- Success overlay shown after polling detects token -->
133
+ <div id="success-overlay">
134
+ <div class="success-icon">✓</div>
135
+ <div class="success-title">登录成功</div>
136
+ <div class="success-sub">微信已连接,可以开始聊天了</div>
78
137
  </div>
79
138
 
80
139
  <script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
81
140
  <script>
82
- const params = new URLSearchParams(location.search);
83
- const url = params.get("url");
84
- const el = document.getElementById("qrcode");
141
+ const params = new URLSearchParams(location.search);
142
+ const url = params.get("url");
143
+ // since: Unix timestamp (seconds) passed by the setup skill when opening this page.
144
+ // We only show success if token_updated_at > since, preventing false positives
145
+ // when the user already had a token from a previous login.
146
+ const since = parseInt(params.get("since") || "0", 10);
147
+ const el = document.getElementById("qrcode");
85
148
 
86
149
  if (!url) {
87
150
  el.innerHTML = '<div class="error">缺少 url 参数</div>';
@@ -99,6 +162,48 @@
99
162
  el.innerHTML = '<div class="error">二维码生成失败: ' + e.message + '</div>';
100
163
  }
101
164
  }
165
+
166
+ // Poll GET /api/channels every 2s; show success overlay once weixin has_token = true
167
+ const POLL_INTERVAL_MS = 2000;
168
+ const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes — matches QR expiry
169
+ const startedAt = Date.now();
170
+ let pollTimer = null;
171
+ let prevHasToken = false;
172
+
173
+ function showSuccess() {
174
+ clearTimeout(pollTimer);
175
+ document.getElementById("success-overlay").classList.add("show");
176
+ }
177
+
178
+ async function pollChannelStatus() {
179
+ if (Date.now() - startedAt > POLL_TIMEOUT_MS) return; // QR expired, stop quietly
180
+
181
+ try {
182
+ const res = await fetch("/api/channels", { cache: "no-store" });
183
+ const data = await res.json();
184
+ const weixin = (data.channels || []).find(c => c.platform === "weixin");
185
+
186
+ if (weixin && weixin.has_token) {
187
+ const updatedAt = weixin.token_updated_at || 0;
188
+ if (updatedAt > since) {
189
+ showSuccess();
190
+ return;
191
+ }
192
+ }
193
+
194
+ // Show "scanned, waiting for confirm" hint once token appears in-progress
195
+ // (iLink doesn't expose a "scanned" state via this API, so we just keep polling)
196
+ } catch (_) {
197
+ // Server temporarily unreachable — keep polling
198
+ }
199
+
200
+ pollTimer = setTimeout(pollChannelStatus, POLL_INTERVAL_MS);
201
+ }
202
+
203
+ // Start polling only when QR code is actually shown
204
+ if (url) {
205
+ pollTimer = setTimeout(pollChannelStatus, POLL_INTERVAL_MS);
206
+ }
102
207
  </script>
103
208
  </body>
104
209
  </html>
data/scripts/install.ps1 CHANGED
@@ -120,7 +120,7 @@ function Invoke-WslStatusExitCode {
120
120
  $psi.UseShellExecute = $false
121
121
  $psi.RedirectStandardOutput = $true
122
122
  $psi.RedirectStandardError = $true
123
- $p = [System.Diagnostics.Process]::Start($psi)
123
+ try { $p = [System.Diagnostics.Process]::Start($psi) } catch { return 1 }
124
124
  $finished = $p.WaitForExit(10000) # 10 seconds
125
125
  if (-not $finished) {
126
126
  $p.Kill()
@@ -29,6 +29,10 @@ CN_NPM_REGISTRY="https://registry.npmmirror.com"
29
29
  CN_NODE_MIRROR_URL="https://cdn.npmmirror.com/binaries/node/"
30
30
  DEFAULT_MISE_INSTALL_URL="https://mise.run"
31
31
  CN_MISE_INSTALL_URL="https://oss.1024code.com/mise.sh"
32
+ # Chrome DMG — update CHROME_VERSION when re-uploading a newer build to OSS
33
+ CHROME_VERSION="134"
34
+ DEFAULT_CHROME_DMG_URL="https://dl.google.com/chrome/mac/universal/stable/GGRO/googlechrome.dmg"
35
+ CN_CHROME_DMG_URL="https://oss.1024code.com/browsers/googlechrome-mac-${CHROME_VERSION}.dmg"
32
36
  MISE_INSTALL_URL="$DEFAULT_MISE_INSTALL_URL"
33
37
  NPM_REGISTRY_URL="$DEFAULT_NPM_REGISTRY"
34
38
  NODE_MIRROR_URL=""
@@ -71,6 +75,48 @@ detect_network_region() {
71
75
  fi
72
76
  }
73
77
 
78
+ # --------------------------------------------------------------------------
79
+ # Ensure Chrome is installed (macOS only)
80
+ # Downloads DMG to Desktop, opens it, then exits with instructions.
81
+ # Re-run the script after Chrome is installed.
82
+ # --------------------------------------------------------------------------
83
+ ensure_chrome_macos() {
84
+ print_step "Checking Google Chrome..."
85
+
86
+ if [ -d "/Applications/Google Chrome.app" ] || [ -d "$HOME/Applications/Google Chrome.app" ]; then
87
+ print_success "Google Chrome is already installed"
88
+ return 0
89
+ fi
90
+
91
+ print_warning "Google Chrome not found — downloading..."
92
+
93
+ local dmg_url
94
+ if [ "$USE_CN_MIRRORS" = true ]; then
95
+ dmg_url="$CN_CHROME_DMG_URL"
96
+ print_info "Using OSS mirror (Chrome ${CHROME_VERSION})"
97
+ else
98
+ dmg_url="$DEFAULT_CHROME_DMG_URL"
99
+ print_info "Using official Google download"
100
+ fi
101
+
102
+ local dmg_path="$HOME/Desktop/googlechrome.dmg"
103
+ print_info "Downloading Chrome (~238 MB) to Desktop..."
104
+
105
+ if ! curl -L --progress-bar "$dmg_url" -o "$dmg_path"; then
106
+ print_error "Download failed"
107
+ print_info "Please download manually: ${dmg_url}"
108
+ exit 1
109
+ fi
110
+
111
+ print_success "Downloaded: ${dmg_path}"
112
+ print_info "Opening DMG installer..."
113
+ open "$dmg_path"
114
+
115
+ echo ""
116
+ print_info "Please drag 'Google Chrome' to the Applications folder, then re-run this script."
117
+ exit 0
118
+ }
119
+
74
120
  # --------------------------------------------------------------------------
75
121
  # Ensure mise is available
76
122
  # --------------------------------------------------------------------------
@@ -178,6 +224,8 @@ main() {
178
224
  echo "========================"
179
225
 
180
226
  detect_network_region
227
+ # Install Chrome first on macOS if not present
228
+ [[ "$(uname)" == "Darwin" ]] && ensure_chrome_macos
181
229
  ensure_node || exit 1
182
230
  install_chrome_devtools_mcp || exit 1
183
231
 
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.24
4
+ version: 0.9.25
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy