openclacky 0.9.21 → 0.9.22

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: d48d1b62badf0b608e9f6dc6cacb6b0d2c8f92263a8df77d64f6c63ee26e3580
4
- data.tar.gz: f4abb3c3aa7dc140a91622135c4eaff7dfbad66bca6908abfb3c76ab7c183629
3
+ metadata.gz: 636621284dfcd7f7329f793bc80f2364f16a2777dedacd2fdf310b2a7f3be58a
4
+ data.tar.gz: 5f3a12e563e36445d3aa32ae050f5b759b204325323cac119b8692bea7d3657b
5
5
  SHA512:
6
- metadata.gz: acfdcca20845b40c076f1974dde40d5d62f194af03c14541a98c02c5fd431790df3e811ac17a0d35276938bb5475a53c3e22e8c8b9f56b87d0637f7a5168d7c5
7
- data.tar.gz: a3ea45c455a7591917801963bb4a08587ea1ab24173d42cb8d1d2718dbf0a17fdf40e11296840dc107863d98e4aa1a2dad8008542b6214a10bda9d575fec7715
6
+ metadata.gz: 23e59f8ae883ded129b4fee900c0ffdf6cec3ada4bd019e9a0243a3942abc70f5bbc8b9cc4830a34d7e66e22e4c663988295eed972ad1df63247c6f0bdbf1a68
7
+ data.tar.gz: 218bd984151af6f087c6ac44f3aa213e19ee30981a1a69484344cdbd00774611465fc3847ecfc0933243e2ae6277792116d96d0aed04b9148a9cfed14f09e14e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.22] - 2026-03-31
11
+
12
+ ### Added
13
+ - **ClackyAI provider (Bedrock with prompt caching)**: added `clackyai` as a first-class provider — uses AWS Bedrock under the hood with prompt caching enabled, normalising token usage to Anthropic semantics so cost calculation works correctly
14
+ - **Browser auto-install script**: `browser-setup` skill can now detect the Chrome/Edge version and automatically download and run the install script, reducing manual setup steps
15
+
16
+ ### Fixed
17
+ - **Feishu setup timeout**: `navigate` method was using `open` (new tab) instead of `navigate` (current tab), causing intermittent timeouts on macOS when opening feishu.cn
18
+ - **Cron task schedule YAML format**: fixed a YAML serialisation bug in the scheduler that produced invalid schedule files
19
+
10
20
  ## [0.9.21] - 2026-03-30
11
21
 
12
22
  ### Fixed
data/lib/clacky/agent.rb CHANGED
@@ -102,6 +102,9 @@ module Clacky
102
102
 
103
103
  # Ensure user-space parsers are in place (~/.clacky/parsers/)
104
104
  Utils::ParserManager.setup!
105
+
106
+ # Ensure bundled shell scripts are in place (~/.clacky/scripts/)
107
+ Utils::ScriptsManager.setup!
105
108
  end
106
109
 
107
110
  # Restore from a saved session
data/lib/clacky/client.rb CHANGED
@@ -88,7 +88,7 @@ module Clacky
88
88
  cloned = deep_clone(messages)
89
89
 
90
90
  if bedrock?
91
- send_bedrock_request(cloned, model, tools, max_tokens)
91
+ send_bedrock_request(cloned, model, tools, max_tokens, caching_enabled)
92
92
  elsif anthropic_format?
93
93
  send_anthropic_request(cloned, model, tools, max_tokens, caching_enabled)
94
94
  else
@@ -124,8 +124,8 @@ module Clacky
124
124
 
125
125
  # ── Bedrock Converse request / response ───────────────────────────────────
126
126
 
127
- def send_bedrock_request(messages, model, tools, max_tokens)
128
- body = MessageFormat::Bedrock.build_request_body(messages, model, tools, max_tokens)
127
+ def send_bedrock_request(messages, model, tools, max_tokens, caching_enabled)
128
+ body = MessageFormat::Bedrock.build_request_body(messages, model, tools, max_tokens, caching_enabled)
129
129
  response = bedrock_connection.post(bedrock_endpoint(model)) { |r| r.body = body.to_json }
130
130
 
131
131
  raise_error(response) unless response.status == 200
@@ -1,8 +1,9 @@
1
1
  ---
2
2
  name: browser-setup
3
3
  description: |
4
- Configure the browser tool for Clacky. Guides the user through Chrome setup,
4
+ Configure the browser tool for Clacky. Guides the user through Chrome or Edge setup,
5
5
  verifies the connection, and writes ~/.clacky/browser.yml.
6
+ Supports macOS, Linux, and WSL (Windows Chrome/Edge via remote debugging).
6
7
  Trigger on: "browser setup", "setup browser", "配置浏览器", "browser config",
7
8
  "browser doctor".
8
9
  Subcommands: setup, doctor.
@@ -31,45 +32,43 @@ If no subcommand is clear, default to `setup`.
31
32
 
32
33
  ## `setup`
33
34
 
34
- ### Step 1 — Check Node.js & install chrome-devtools-mcp
35
+ ### Step 1 — Ensure chrome-devtools-mcp is installed
35
36
 
36
- Run:
37
+ Check if already installed:
37
38
  ```bash
38
- node --version
39
+ chrome-devtools-mcp --version 2>/dev/null
39
40
  ```
40
41
 
41
- If Node.js is missing or version < 20, tell the user and stop:
42
+ If found and exits 0 skip to Step 2.
42
43
 
43
- > The browser tool requires Node.js 20+.
44
- > Please install it first: https://nodejs.org/
45
- > Let me know when done and I'll retry.
46
-
47
- Then install/update `chrome-devtools-mcp`:
44
+ If missing, run the bundled installer (handles Node.js + chrome-devtools-mcp + CN/global mirrors automatically):
48
45
  ```bash
49
- npm install -g chrome-devtools-mcp@latest
46
+ bash ~/.clacky/scripts/install_browser.sh
50
47
  ```
51
48
 
52
- If this fails, stop and tell the user:
49
+ If the script exits non-zero or `~/.clacky/scripts/install_browser.sh` doesn't exist, stop and tell the user:
53
50
 
54
- > ❌ Failed to install chrome-devtools-mcp. Please run manually:
51
+ > ❌ Failed to install chrome-devtools-mcp automatically.
52
+ > Please run manually:
55
53
  > ```
56
54
  > npm install -g chrome-devtools-mcp@latest
57
55
  > ```
56
+ > (Requires Node.js 20+. If Node.js is missing, install it first, then retry.)
58
57
  > Let me know when done.
59
58
 
60
- ### Step 2 — Try to connect to Chrome
59
+ ### Step 2 — Try to connect to the browser
61
60
 
62
61
  Immediately attempt to connect — do **not** ask the user anything first:
63
62
 
64
63
  ```
65
- browser(action="act", kind="evaluate", js="navigator.userAgentData?.brands?.find(b => b.brand === 'Google Chrome')?.version || navigator.userAgent.match(/Chrome\\/([\\d]+)/)?.[1] || 'unknown'")
64
+ browser(action="act", kind="evaluate", js="navigator.userAgentData?.brands?.find(b => b.brand === 'Google Chrome' || b.brand === 'Microsoft Edge')?.version || navigator.userAgent.match(/Chrome\/([\d]+)/)?.[1] || 'unknown'")
66
65
  ```
67
66
 
68
- **If connection succeeds** → parse the Chrome version and jump to Step 3.
67
+ **If connection succeeds** → parse the browser version and jump to Step 3.
69
68
 
70
69
  **If connection fails** → inspect the error message from the evaluate result to diagnose:
71
70
 
72
- **Case A — error contains `"timed out"`**: The MCP daemon failed to start — Chrome is not running or remote debugging is not enabled. Try to open the page for the user:
71
+ **Case A — error contains `"timed out"`**: The MCP daemon failed to start — Chrome or Edge is not running or remote debugging is not enabled. Try to open the page for the user:
73
72
 
74
73
  ```bash
75
74
  open "chrome://inspect/#remote-debugging"
@@ -77,35 +76,38 @@ open "chrome://inspect/#remote-debugging"
77
76
 
78
77
  Tell the user:
79
78
 
80
- > I've opened `chrome://inspect/#remote-debugging` in Chrome.
79
+ > I've opened `chrome://inspect/#remote-debugging` in your browser.
81
80
  > Please click **"Allow remote debugging for this browser instance"** and let me know when done.
82
81
 
83
- If `open` fails, fall back to:
82
+ If `open` fails (e.g. on WSL or Linux), fall back to:
84
83
 
85
- > Please open this URL in Chrome:
84
+ > Please open this URL in Chrome or Edge:
86
85
  > `chrome://inspect/#remote-debugging`
87
86
  > Then click **"Allow remote debugging for this browser instance"** and let me know when done.
87
+ >
88
+ > On WSL: launch your browser from PowerShell with remote debugging enabled:
89
+ > - Edge: `Start-Process msedge --ArgumentList "--remote-debugging-port=9222"`
90
+ > - Chrome: `Start-Process chrome --ArgumentList "--remote-debugging-port=9222"`
88
91
 
89
92
  Wait for the user to confirm, then retry the connection once. If still failing, stop:
90
93
 
91
- > ❌ Could not connect to Chrome. Please make sure Chrome is open and remote debugging is enabled, then run `/browser-setup` again.
94
+ > ❌ Could not connect to the browser. Please make sure Chrome or Edge is open and remote debugging is enabled, then run `/browser-setup` again.
92
95
 
93
- **Case B — error contains `"Chrome MCP error:"`**: The MCP daemon is alive but Chrome's CDP connection is broken — this is a known Chrome issue after long sessions. Tell the user:
96
+ **Case B — error contains `"Chrome MCP error:"`**: The MCP daemon is alive but the browser's CDP connection is broken — this is a known issue after long sessions. Tell the user:
94
97
 
95
- > Chrome's remote debugging connection is unstable.
96
- > Please restart Chrome and let me know when done.
98
+ > The browser's remote debugging connection is unstable.
99
+ > Please restart your browser and let me know when done.
97
100
 
98
101
  Wait for the user to confirm, then retry once. If still failing, stop with the same error message as Case A.
99
102
 
100
- ### Step 3 — Check Chrome version
103
+ ### Step 3 — Check browser version
101
104
 
102
- Parse the version number from Step 2:
105
+ Parse the version number from Step 2 (works for both Chrome and Edge, both are Chromium-based):
103
106
  - version >= 146 → proceed
104
107
  - version 144–145 → warn but proceed:
105
- > ⚠️ Your Chrome version is vXXX. Version 146+ is recommended. Continuing anyway...
108
+ > ⚠️ Your browser version is vXXX. Version 146+ is recommended. Continuing anyway...
106
109
  - version < 144 or unknown → stop:
107
- > ❌ Chrome vXXX is too old. Please upgrade to Chrome 146+: https://www.google.com/chrome/
108
- > Let me know when you've upgraded and I'll retry.
110
+ > ❌ Browser vXXX is too old. Please upgrade Chrome or Edge to v146+, then let me know and I'll retry.
109
111
 
110
112
  ### Step 4 — Save config and start daemon
111
113
 
@@ -122,7 +124,7 @@ If this fails (server not running), skip silently — the daemon will start lazi
122
124
 
123
125
  > ✅ Browser configured.
124
126
  >
125
- > Chrome v<VERSION> is connected and ready to use.
127
+ > Chrome/Edge v<VERSION> is connected and ready to use.
126
128
 
127
129
  ---
128
130
 
@@ -125,7 +125,7 @@ class BrowserSession
125
125
  end
126
126
 
127
127
  def navigate(url)
128
- @client.call("open", url: url)
128
+ @client.call("navigate", url: url)
129
129
  sleep 2
130
130
  snapshot
131
131
  end
@@ -53,6 +53,7 @@ Answer the user's question using the official documentation below. Always fetch
53
53
  | Built-in skills, default skills, what skills ship with OpenClacky | https://www.openclacky.com/docs/built-in-skills |
54
54
  | Memory system, long-term memory, ~/.clacky/memories | https://www.openclacky.com/docs/memory-system |
55
55
  | Session management, conversation history, context window | https://www.openclacky.com/docs/session-management |
56
+ | Browser automation, browser tool, Chrome, Edge, CDP, remote debugging, WSL browser, browser-setup skill | https://www.openclacky.com/docs/browser-tool |
56
57
 
57
58
  ## Workflow
58
59
 
@@ -32,13 +32,21 @@ module Clacky
32
32
  # @param max_tokens [Integer]
33
33
  # @param caching_enabled [Boolean] (currently unused for Bedrock)
34
34
  # @return [Hash] ready to serialize as JSON body
35
- def build_request_body(messages, model, tools, max_tokens, _caching_enabled = false)
35
+ def build_request_body(messages, model, tools, max_tokens, caching_enabled = false)
36
36
  system_messages = messages.select { |m| m[:role] == "system" }
37
37
  regular_messages = messages.reject { |m| m[:role] == "system" }
38
38
 
39
39
  # Merge consecutive same-role messages (Bedrock requires alternating roles)
40
40
  api_messages = merge_consecutive_tool_results(regular_messages.map { |msg| to_api_message(msg) })
41
41
 
42
+ # Inject cachePoint blocks AFTER conversion to Bedrock API format.
43
+ # Doing this on canonical messages (before to_api_message) is incorrect because
44
+ # tool-result messages (role: "tool") are converted to toolResult blocks, and
45
+ # Bedrock does not support cachePoint inside toolResult.content.
46
+ # Operating on the final Bedrock format ensures cachePoint is always a top-level
47
+ # sibling block in the message's content array, which is what Bedrock expects.
48
+ api_messages = apply_api_caching(api_messages) if caching_enabled
49
+
42
50
  body = { messages: api_messages }
43
51
 
44
52
  # Add system prompt if present
@@ -86,11 +94,24 @@ module Clacky
86
94
  else data["stopReason"]
87
95
  end
88
96
 
97
+ cache_read = usage["cacheReadInputTokens"].to_i
98
+ cache_write = usage["cacheWriteInputTokens"].to_i
99
+
100
+ # Bedrock `inputTokens` = non-cached input only.
101
+ # Anthropic direct `input_tokens` = all input including cache_read.
102
+ # Normalise to Anthropic semantics so ModelPricing.calculate_cost works correctly:
103
+ # prompt_tokens = inputTokens + cacheReadInputTokens
104
+ # (calculate_cost subtracts cache_read_tokens from prompt_tokens to get
105
+ # the billable non-cached portion; that arithmetic requires the Anthropic convention.)
106
+ prompt_tokens = usage["inputTokens"].to_i + cache_read
107
+
89
108
  usage_data = {
90
- prompt_tokens: usage["inputTokens"].to_i,
109
+ prompt_tokens: prompt_tokens,
91
110
  completion_tokens: usage["outputTokens"].to_i,
92
111
  total_tokens: usage["totalTokens"].to_i
93
112
  }
113
+ usage_data[:cache_read_input_tokens] = cache_read if cache_read > 0
114
+ usage_data[:cache_creation_input_tokens] = cache_write if cache_write > 0
94
115
 
95
116
  { content: content, tool_calls: tool_calls, finish_reason: finish_reason,
96
117
  usage: usage_data, raw_api_usage: usage }
@@ -185,6 +206,8 @@ module Clacky
185
206
  when "image"
186
207
  block # already Bedrock format
187
208
  else
209
+ # Pass through Bedrock-native blocks (e.g. cachePoint) unchanged
210
+ return block if block[:cachePoint]
188
211
  # Fallback: try to extract text
189
212
  { text: (block[:text] || block.to_s) }
190
213
  end
@@ -252,6 +275,38 @@ module Clacky
252
275
  end
253
276
  merged
254
277
  end
278
+
279
+ # Inject cachePoint blocks into already-converted Bedrock API format messages.
280
+ # Marks the last 2 messages (from the tail) so Bedrock can cache the conversation
281
+ # prefix up to those points.
282
+ #
283
+ # Why operate on Bedrock API format (not canonical):
284
+ # - tool-result canonical messages (role: "tool") become toolResult blocks inside
285
+ # a user message. Bedrock does NOT allow cachePoint inside toolResult.content.
286
+ # - After merge_consecutive_tool_results, message boundaries may differ from canonical.
287
+ # - Operating here guarantees cachePoint is always a top-level sibling block.
288
+ private_class_method def self.apply_api_caching(api_messages)
289
+ return api_messages if api_messages.empty?
290
+
291
+ candidate_indices = []
292
+ (api_messages.length - 1).downto(0) do |i|
293
+ break if candidate_indices.length >= 2
294
+ candidate_indices << i
295
+ end
296
+
297
+ api_messages.map.with_index do |msg, idx|
298
+ next msg unless candidate_indices.include?(idx)
299
+
300
+ content = msg[:content]
301
+ next msg unless content.is_a?(Array)
302
+
303
+ # Don't double-add cachePoint if already present
304
+ already_marked = content.last.is_a?(Hash) && content.last[:cachePoint]
305
+ next msg if already_marked
306
+
307
+ msg.merge(content: content + [{ cachePoint: { type: "default" } }])
308
+ end
309
+ end
255
310
  end
256
311
  end
257
312
  end
@@ -48,16 +48,16 @@ module Clacky
48
48
  "website_url" => "https://console.anthropic.com/settings/keys"
49
49
  }.freeze,
50
50
 
51
- "bedrock-jp" => {
52
- "name" => "AWS Bedrock (JP)",
53
- "base_url" => "https://bedrock-runtime.ap-northeast-1.amazonaws.com",
51
+ "clackyai" => {
52
+ "name" => "Clacky AI",
53
+ "base_url" => "https://api.clacky.ai",
54
54
  "api" => "bedrock",
55
55
  "default_model" => "jp.anthropic.claude-sonnet-4-6",
56
56
  "models" => [
57
57
  "jp.anthropic.claude-sonnet-4-6",
58
58
  "jp.anthropic.claude-haiku-4-6"
59
59
  ],
60
- "website_url" => "https://console.aws.amazon.com/iam/home#/security_credentials"
60
+ "website_url" => "https://clacky.ai"
61
61
  }.freeze
62
62
 
63
63
  }.freeze
@@ -300,7 +300,8 @@ module Clacky
300
300
  return [] unless File.exist?(SCHEDULES_FILE)
301
301
 
302
302
  data = YAMLCompat.load_file(SCHEDULES_FILE, permitted_classes: [Symbol])
303
- Array(data)
303
+ raw = data.is_a?(Hash) ? data["schedules"] : data
304
+ Array(raw).select { |s| s.is_a?(Hash) }
304
305
  rescue => e
305
306
  Clacky::Logger.error("scheduler_load_schedules_error", error: e)
306
307
  []
@@ -542,9 +542,33 @@ module Clacky
542
542
  end
543
543
 
544
544
  def self.build_mcp_command(user_data_dir: nil)
545
- args = CHROME_MCP_BASE_ARGS.dup
546
- args += ["--userDataDir", user_data_dir.to_s] if user_data_dir && !user_data_dir.to_s.empty?
547
- ["chrome-devtools-mcp", *args]
545
+ # If an explicit user_data_dir is given, use it directly (e.g. from browser.yml).
546
+ if user_data_dir && !user_data_dir.to_s.empty?
547
+ return ["chrome-devtools-mcp", *CHROME_MCP_BASE_ARGS, "--userDataDir", user_data_dir.to_s]
548
+ end
549
+
550
+ # On non-macOS/Linux platforms (especially WSL), chrome-devtools-mcp's built-in
551
+ # --autoConnect only scans Linux-side paths and misses Windows-side Chrome/Edge.
552
+ # Use BrowserDetector to find any running debuggable browser across all platforms.
553
+ detected = Clacky::Utils::BrowserDetector.detect
554
+
555
+ args = base_args_without_autoconnect
556
+ case detected&.fetch(:mode)
557
+ when :ws_endpoint
558
+ # DevToolsActivePort found — use exact WS endpoint (most reliable)
559
+ ["chrome-devtools-mcp", *args, "--wsEndpoint", detected[:value]]
560
+ when :browser_url
561
+ # TCP port reachable — let chrome-devtools-mcp handle WS negotiation
562
+ ["chrome-devtools-mcp", *args, "--browserUrl", detected[:value]]
563
+ else
564
+ # Nothing detected — fall back to --autoConnect (works on plain macOS/Linux)
565
+ ["chrome-devtools-mcp", *CHROME_MCP_BASE_ARGS]
566
+ end
567
+ end
568
+
569
+ # Base args without --autoConnect, used when we supply explicit connection info.
570
+ def self.base_args_without_autoconnect
571
+ CHROME_MCP_BASE_ARGS.reject { |a| a == "--autoConnect" }
548
572
  end
549
573
 
550
574
  # Delegate to BrowserManager. Auto-retries once on "selected page has been closed".
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Clacky
6
+ module Utils
7
+ # Detects a running browser (Chrome/Edge) that has remote debugging enabled.
8
+ #
9
+ # Detection strategy (in priority order):
10
+ #
11
+ # 1. Scan known UserData directories for DevToolsActivePort file.
12
+ # This file contains the exact port + WS path — most reliable.
13
+ # Returns { mode: :ws_endpoint, value: "ws://127.0.0.1:PORT/PATH" }
14
+ #
15
+ # 2. TCP port scan on common remote debugging ports (9222–9224).
16
+ # Chrome 146+ dropped HTTP /json/version, so we just probe TCP connectivity
17
+ # and hand off to chrome-devtools-mcp via --browserUrl.
18
+ # Returns { mode: :browser_url, value: "http://127.0.0.1:PORT" }
19
+ #
20
+ # 3. Nothing found → returns nil (caller should show guidance to user).
21
+ #
22
+ # Supported environments: WSL, Linux, macOS.
23
+ module BrowserDetector
24
+ # Ports to probe when DevToolsActivePort file is not found.
25
+ TCP_PROBE_PORTS = [9222, 9223, 9224].freeze
26
+ TCP_PROBE_TIMEOUT = 0.5 # seconds
27
+
28
+ # Detect a running debuggable browser.
29
+ # @return [Hash, nil] { mode: :ws_endpoint|:browser_url, value: String } or nil
30
+ def self.detect
31
+ result = detect_via_active_port_file
32
+ result ||= detect_via_tcp_probe
33
+ result
34
+ end
35
+
36
+ # -----------------------------------------------------------------------
37
+ # Strategy 1: DevToolsActivePort file scan
38
+ # -----------------------------------------------------------------------
39
+
40
+ # @return [Hash, nil]
41
+ def self.detect_via_active_port_file
42
+ user_data_dirs.each do |dir|
43
+ port_file = File.join(dir, "DevToolsActivePort")
44
+ next unless File.exist?(port_file)
45
+
46
+ ws = parse_active_port_file(port_file)
47
+ return { mode: :ws_endpoint, value: ws } if ws
48
+ end
49
+ nil
50
+ end
51
+
52
+ # @return [Hash, nil]
53
+ def self.detect_via_tcp_probe
54
+ TCP_PROBE_PORTS.each do |port|
55
+ return { mode: :browser_url, value: "http://127.0.0.1:#{port}" } if tcp_open?("127.0.0.1", port)
56
+ end
57
+ nil
58
+ end
59
+
60
+ # -----------------------------------------------------------------------
61
+ # UserData directory candidates per OS
62
+ # -----------------------------------------------------------------------
63
+
64
+ # Returns ordered list of candidate UserData dirs to check.
65
+ # @return [Array<String>]
66
+ def self.user_data_dirs
67
+ case EnvironmentDetector.os_type
68
+ when :wsl then wsl_user_data_dirs
69
+ when :linux then linux_user_data_dirs
70
+ when :macos then macos_user_data_dirs
71
+ else []
72
+ end
73
+ end
74
+
75
+ # WSL: Chrome/Edge run on Windows side — resolve via LOCALAPPDATA.
76
+ private_class_method def self.wsl_user_data_dirs
77
+ appdata = Utils::Encoding.cmd_to_utf8(
78
+ `powershell.exe -NoProfile -Command '$env:LOCALAPPDATA' 2>/dev/null`
79
+ ).strip.tr("\r\n", "")
80
+ return [] if appdata.empty?
81
+
82
+ win_paths = [
83
+ "#{appdata}\\Microsoft\\Edge\\User Data",
84
+ "#{appdata}\\Google\\Chrome\\User Data",
85
+ "#{appdata}\\Google\\Chrome Beta\\User Data",
86
+ "#{appdata}\\Google\\Chrome SxS\\User Data",
87
+ ]
88
+
89
+ win_paths.filter_map do |win_path|
90
+ linux_path = Utils::Encoding.cmd_to_utf8(
91
+ `wslpath '#{win_path}' 2>/dev/null`, source_encoding: "UTF-8"
92
+ ).strip
93
+ linux_path.empty? ? nil : linux_path
94
+ end
95
+ end
96
+
97
+ # Linux: standard XDG config paths for Chrome and Edge.
98
+ private_class_method def self.linux_user_data_dirs
99
+ config_home = ENV["XDG_CONFIG_HOME"] || File.join(Dir.home, ".config")
100
+ [
101
+ File.join(config_home, "microsoft-edge"),
102
+ File.join(config_home, "google-chrome"),
103
+ File.join(config_home, "google-chrome-beta"),
104
+ File.join(config_home, "google-chrome-unstable"),
105
+ ]
106
+ end
107
+
108
+ # macOS: Application Support paths for Chrome and Edge.
109
+ private_class_method def self.macos_user_data_dirs
110
+ base = File.join(Dir.home, "Library", "Application Support")
111
+ [
112
+ File.join(base, "Microsoft Edge"),
113
+ File.join(base, "Google", "Chrome"),
114
+ File.join(base, "Google", "Chrome Beta"),
115
+ File.join(base, "Google", "Chrome Canary"),
116
+ ]
117
+ end
118
+
119
+ # -----------------------------------------------------------------------
120
+ # Helpers
121
+ # -----------------------------------------------------------------------
122
+
123
+ # Parse DevToolsActivePort file.
124
+ # Format: first line = port number, second line = WS path
125
+ # @return [String, nil] ws://127.0.0.1:PORT/PATH or nil on parse error
126
+ private_class_method def self.parse_active_port_file(path)
127
+ lines = File.read(path, encoding: "utf-8").split("\n").map(&:strip).reject(&:empty?)
128
+ return nil unless lines.size >= 2
129
+
130
+ port = lines[0].to_i
131
+ ws_path = lines[1]
132
+ return nil if port <= 0 || port > 65_535 || ws_path.empty?
133
+
134
+ "ws://127.0.0.1:#{port}#{ws_path}"
135
+ rescue StandardError
136
+ nil
137
+ end
138
+
139
+ # Probe TCP port with a short timeout.
140
+ # Chrome 146+ dropped HTTP /json/version — TCP reachability is sufficient.
141
+ # @return [Boolean]
142
+ private_class_method def self.tcp_open?(host, port)
143
+ Socket.tcp(host, port, connect_timeout: TCP_PROBE_TIMEOUT) { true }
144
+ rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError, Errno::EHOSTUNREACH
145
+ false
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Clacky
6
+ module Utils
7
+ # Manages user-space shell scripts in ~/.clacky/scripts/.
8
+ #
9
+ # On first use, bundled scripts are copied from the gem's scripts/
10
+ # directory into ~/.clacky/scripts/. The user-space copy is always
11
+ # used so users can customise scripts without modifying the gem.
12
+ #
13
+ # Bundled scripts are re-copied when the gem is upgraded (detected
14
+ # via gem version stamp in ~/.clacky/scripts/.version).
15
+ module ScriptsManager
16
+ SCRIPTS_DIR = File.expand_path("~/.clacky/scripts").freeze
17
+ DEFAULT_SCRIPTS_DIR = File.expand_path("../../../../scripts", __dir__).freeze
18
+ VERSION_FILE = File.join(SCRIPTS_DIR, ".version").freeze
19
+
20
+ SCRIPTS = %w[
21
+ install_browser.sh
22
+ ].freeze
23
+
24
+ # Copy bundled scripts to ~/.clacky/scripts/ if missing or outdated.
25
+ # Called once at agent startup — fast (no-op after first run).
26
+ def self.setup!
27
+ FileUtils.mkdir_p(SCRIPTS_DIR)
28
+
29
+ current_version = Clacky::VERSION
30
+ stored_version = File.exist?(VERSION_FILE) ? File.read(VERSION_FILE).strip : nil
31
+
32
+ SCRIPTS.each do |script|
33
+ dest = File.join(SCRIPTS_DIR, script)
34
+ src = File.join(DEFAULT_SCRIPTS_DIR, script)
35
+ next unless File.exist?(src)
36
+
37
+ # Copy if missing or gem was upgraded
38
+ if !File.exist?(dest) || stored_version != current_version
39
+ FileUtils.cp(src, dest)
40
+ FileUtils.chmod(0o755, dest)
41
+ end
42
+ end
43
+
44
+ # Write version stamp after successful copy
45
+ File.write(VERSION_FILE, current_version)
46
+ end
47
+
48
+ # Returns the full path to a managed script.
49
+ # @param name [String] script filename, e.g. "install_browser.sh"
50
+ # @return [String, nil]
51
+ def self.path_for(name)
52
+ dest = File.join(SCRIPTS_DIR, name)
53
+ File.exist?(dest) ? dest : nil
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.9.21"
4
+ VERSION = "0.9.22"
5
5
  end
@@ -107,8 +107,7 @@ const Tasks = (() => {
107
107
 
108
108
  /** Called by Router when the tasks panel becomes active. */
109
109
  onPanelShow() {
110
- Tasks.renderTable();
111
- Tasks.renderSection();
110
+ Tasks.load();
112
111
  const btn = $("btn-create-task");
113
112
  if (btn) btn.onclick = () => Tasks.createInSession();
114
113
  },
data/lib/clacky.rb CHANGED
@@ -89,6 +89,9 @@ require_relative "clacky/ui2/progress_indicator"
89
89
  # Utils
90
90
  require_relative "clacky/utils/logger"
91
91
  require_relative "clacky/utils/encoding"
92
+ require_relative "clacky/utils/environment_detector"
93
+ require_relative "clacky/utils/browser_detector"
94
+ require_relative "clacky/utils/scripts_manager"
92
95
  require_relative "clacky/utils/model_pricing"
93
96
  require_relative "clacky/utils/gitignore_parser"
94
97
  require_relative "clacky/utils/limit_stack"
@@ -0,0 +1,189 @@
1
+ #!/bin/bash
2
+ # Install Node.js (via mise) and chrome-devtools-mcp for browser automation.
3
+ # This script is copied to ~/.clacky/scripts/ on first run and invoked by
4
+ # the browser-setup skill when chrome-devtools-mcp is not yet installed.
5
+
6
+ set -e
7
+
8
+ RED='\033[0;31m'
9
+ GREEN='\033[0;32m'
10
+ YELLOW='\033[1;33m'
11
+ BLUE='\033[0;34m'
12
+ NC='\033[0m'
13
+
14
+ print_info() { echo -e "${BLUE}ℹ${NC} $1"; }
15
+ print_success() { echo -e "${GREEN}✓${NC} $1"; }
16
+ print_warning() { echo -e "${YELLOW}⚠${NC} $1"; }
17
+ print_error() { echo -e "${RED}✗${NC} $1"; }
18
+ print_step() { echo -e "\n${BLUE}==>${NC} $1"; }
19
+
20
+ command_exists() { command -v "$1" >/dev/null 2>&1; }
21
+
22
+ # --------------------------------------------------------------------------
23
+ # Network region detection (quick — only probes google + baidu)
24
+ # --------------------------------------------------------------------------
25
+ SLOW_THRESHOLD_MS=5000
26
+ USE_CN_MIRRORS=false
27
+ DEFAULT_NPM_REGISTRY="https://registry.npmjs.org"
28
+ CN_NPM_REGISTRY="https://registry.npmmirror.com"
29
+ CN_NODE_MIRROR_URL="https://cdn.npmmirror.com/binaries/node/"
30
+ DEFAULT_MISE_INSTALL_URL="https://mise.run"
31
+ CN_MISE_INSTALL_URL="https://oss.1024code.com/mise.sh"
32
+ MISE_INSTALL_URL="$DEFAULT_MISE_INSTALL_URL"
33
+ NPM_REGISTRY_URL="$DEFAULT_NPM_REGISTRY"
34
+ NODE_MIRROR_URL=""
35
+
36
+ _probe_url() {
37
+ local url="$1"
38
+ local out
39
+ out=$(curl -s -o /dev/null -w "%{http_code} %{time_total}" \
40
+ --connect-timeout 5 --max-time 5 "$url" 2>/dev/null) || true
41
+ local http_code="${out%% *}"
42
+ local total_time="${out#* }"
43
+ if [ -z "$http_code" ] || [ "$http_code" = "000" ] || [ "$http_code" = "$out" ]; then
44
+ echo "timeout"; return
45
+ fi
46
+ awk -v s="$total_time" 'BEGIN { printf "%d", s * 1000 }'
47
+ }
48
+
49
+ _is_slow() {
50
+ local r="$1"
51
+ [ "$r" = "timeout" ] && return 0
52
+ [ "$r" -ge "$SLOW_THRESHOLD_MS" ] 2>/dev/null
53
+ }
54
+
55
+ detect_network_region() {
56
+ print_step "Detecting network region..."
57
+ local google baidu
58
+ google=$(_probe_url "https://www.google.com")
59
+ baidu=$(_probe_url "https://www.baidu.com")
60
+
61
+ if ! _is_slow "$google"; then
62
+ print_info "Region: global"
63
+ elif ! _is_slow "$baidu"; then
64
+ print_info "Region: china — switching to CN mirrors"
65
+ USE_CN_MIRRORS=true
66
+ MISE_INSTALL_URL="$CN_MISE_INSTALL_URL"
67
+ NPM_REGISTRY_URL="$CN_NPM_REGISTRY"
68
+ NODE_MIRROR_URL="$CN_NODE_MIRROR_URL"
69
+ else
70
+ print_warning "Region: unknown — using global defaults"
71
+ fi
72
+ }
73
+
74
+ # --------------------------------------------------------------------------
75
+ # Ensure mise is available
76
+ # --------------------------------------------------------------------------
77
+ _mise_bin() {
78
+ if command_exists mise; then echo "mise"
79
+ elif [ -x "$HOME/.local/bin/mise" ]; then echo "$HOME/.local/bin/mise"
80
+ else echo ""
81
+ fi
82
+ }
83
+
84
+ ensure_mise() {
85
+ local mise
86
+ mise=$(_mise_bin)
87
+ if [ -n "$mise" ]; then
88
+ print_success "mise already installed"
89
+ export PATH="$HOME/.local/bin:$PATH"
90
+ eval "$("$mise" activate bash 2>/dev/null)" 2>/dev/null || true
91
+ return 0
92
+ fi
93
+
94
+ print_info "Installing mise..."
95
+ if curl -fsSL "$MISE_INSTALL_URL" | sh; then
96
+ export PATH="$HOME/.local/bin:$PATH"
97
+ eval "$(~/.local/bin/mise activate bash 2>/dev/null)" 2>/dev/null || true
98
+ print_success "mise installed"
99
+ else
100
+ print_warning "mise install failed — will rely on system Node if available"
101
+ return 1
102
+ fi
103
+ }
104
+
105
+ # --------------------------------------------------------------------------
106
+ # Ensure Node.js >= 20 via mise
107
+ # --------------------------------------------------------------------------
108
+ ensure_node() {
109
+ print_step "Checking Node.js..."
110
+
111
+ # Already have a good node?
112
+ if command_exists node; then
113
+ local ver
114
+ ver=$(node --version 2>/dev/null | sed 's/v//')
115
+ local major="${ver%%.*}"
116
+ if [ "${major:-0}" -ge 20 ] 2>/dev/null; then
117
+ print_success "Node.js v${ver} — OK"
118
+ return 0
119
+ else
120
+ print_warning "Node.js v${ver} is too old (need >=20), will install via mise"
121
+ fi
122
+ fi
123
+
124
+ # Install via mise
125
+ if ! ensure_mise; then
126
+ print_error "Cannot install Node.js: mise unavailable and no suitable node found"
127
+ return 1
128
+ fi
129
+
130
+ local mise
131
+ mise=$(_mise_bin)
132
+
133
+ if [ "$USE_CN_MIRRORS" = true ] && [ -n "$NODE_MIRROR_URL" ]; then
134
+ "$mise" settings node.mirror_url="$NODE_MIRROR_URL" 2>/dev/null || true
135
+ print_info "Node mirror → ${NODE_MIRROR_URL}"
136
+ fi
137
+
138
+ print_info "Installing Node.js 22 via mise..."
139
+ "$mise" install node@22 >/dev/null 2>&1 || true
140
+ "$mise" use -g node@22 >/dev/null 2>&1 || true
141
+ eval "$("$mise" activate bash 2>/dev/null)" 2>/dev/null || true
142
+
143
+ if command_exists node; then
144
+ print_success "Node.js $(node --version) installed"
145
+ else
146
+ print_error "Node.js installation failed"
147
+ return 1
148
+ fi
149
+ }
150
+
151
+ # --------------------------------------------------------------------------
152
+ # Install / update chrome-devtools-mcp
153
+ # --------------------------------------------------------------------------
154
+ install_chrome_devtools_mcp() {
155
+ print_step "Installing chrome-devtools-mcp..."
156
+
157
+ # Set npm registry
158
+ if [ "$USE_CN_MIRRORS" = true ]; then
159
+ npm config set registry "$NPM_REGISTRY_URL" 2>/dev/null || true
160
+ print_info "npm registry → ${NPM_REGISTRY_URL}"
161
+ fi
162
+
163
+ if npm install -g chrome-devtools-mcp@latest 2>/dev/null; then
164
+ print_success "chrome-devtools-mcp $(chrome-devtools-mcp --version 2>/dev/null) installed"
165
+ else
166
+ print_error "chrome-devtools-mcp installation failed"
167
+ print_info "Try manually: npm install -g chrome-devtools-mcp@latest"
168
+ return 1
169
+ fi
170
+ }
171
+
172
+ # --------------------------------------------------------------------------
173
+ # Main
174
+ # --------------------------------------------------------------------------
175
+ main() {
176
+ echo ""
177
+ echo "Browser Automation Setup"
178
+ echo "========================"
179
+
180
+ detect_network_region
181
+ ensure_node || exit 1
182
+ install_chrome_devtools_mcp || exit 1
183
+
184
+ echo ""
185
+ print_success "Done. Browser automation is ready."
186
+ echo ""
187
+ }
188
+
189
+ main "$@"
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.21
4
+ version: 0.9.22
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy
@@ -448,6 +448,7 @@ files:
448
448
  - lib/clacky/ui2/view_renderer.rb
449
449
  - lib/clacky/ui_interface.rb
450
450
  - lib/clacky/utils/arguments_parser.rb
451
+ - lib/clacky/utils/browser_detector.rb
451
452
  - lib/clacky/utils/encoding.rb
452
453
  - lib/clacky/utils/environment_detector.rb
453
454
  - lib/clacky/utils/file_ignore_helper.rb
@@ -458,6 +459,7 @@ files:
458
459
  - lib/clacky/utils/model_pricing.rb
459
460
  - lib/clacky/utils/parser_manager.rb
460
461
  - lib/clacky/utils/path_helper.rb
462
+ - lib/clacky/utils/scripts_manager.rb
461
463
  - lib/clacky/utils/string_matcher.rb
462
464
  - lib/clacky/utils/trash_directory.rb
463
465
  - lib/clacky/utils/workspace_rules.rb
@@ -482,6 +484,7 @@ files:
482
484
  - lib/clacky/web/ws.js
483
485
  - scripts/install.ps1
484
486
  - scripts/install.sh
487
+ - scripts/install_browser.sh
485
488
  - scripts/install_full.sh
486
489
  - scripts/uninstall.sh
487
490
  - sig/clacky.rbs