openclacky 0.9.7 → 0.9.9
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 +4 -4
- data/.clacky/skills/gem-release/SKILL.md +16 -1
- data/CHANGELOG.md +51 -0
- data/docs/browser-cdp-native-design.md +195 -0
- data/docs/session-management-redesign.md +202 -0
- data/docs/system-skill-authoring-guide.md +47 -0
- data/lib/clacky/agent/cost_tracker.rb +2 -1
- data/lib/clacky/agent/message_compressor_helper.rb +2 -2
- data/lib/clacky/agent/session_serializer.rb +36 -3
- data/lib/clacky/agent/skill_manager.rb +68 -5
- data/lib/clacky/agent/system_prompt_builder.rb +30 -13
- data/lib/clacky/agent/tool_executor.rb +8 -12
- data/lib/clacky/agent.rb +172 -30
- data/lib/clacky/brand_config.rb +90 -0
- data/lib/clacky/cli.rb +37 -8
- data/lib/clacky/default_parsers/doc_parser.rb +69 -0
- data/lib/clacky/default_parsers/docx_parser.rb +172 -0
- data/lib/clacky/default_parsers/pdf_parser.rb +79 -0
- data/lib/clacky/default_parsers/pptx_parser.rb +140 -0
- data/lib/clacky/default_parsers/xlsx_parser.rb +121 -0
- data/lib/clacky/default_skills/browser-setup/SKILL.md +158 -0
- data/lib/clacky/default_skills/channel-setup/SKILL.md +139 -42
- data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +582 -0
- data/lib/clacky/default_skills/channel-setup/weixin_setup.rb +274 -0
- data/lib/clacky/default_skills/onboard/SKILL.md +132 -6
- data/lib/clacky/default_skills/personal-website/SKILL.md +113 -0
- data/lib/clacky/default_skills/personal-website/publish.rb +214 -0
- data/lib/clacky/default_skills/skill-add/SKILL.md +6 -7
- data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +1 -1
- data/lib/clacky/default_skills/skill-creator/SKILL.md +3 -3
- data/lib/clacky/json_ui_controller.rb +5 -0
- data/lib/clacky/message_format/open_ai.rb +7 -1
- data/lib/clacky/plain_ui_controller.rb +5 -0
- data/lib/clacky/server/browser_manager.rb +308 -0
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +80 -15
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +216 -7
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +12 -0
- data/lib/clacky/server/channel/adapters/wecom/adapter.rb +2 -8
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +391 -0
- data/lib/clacky/server/channel/adapters/weixin/api_client.rb +374 -0
- data/lib/clacky/server/channel/channel_config.rb +6 -0
- data/lib/clacky/server/channel/channel_manager.rb +50 -15
- data/lib/clacky/server/channel/channel_ui_controller.rb +18 -0
- data/lib/clacky/server/channel.rb +1 -0
- data/lib/clacky/server/http_server.rb +210 -45
- data/lib/clacky/server/scheduler.rb +1 -1
- data/lib/clacky/server/session_registry.rb +131 -40
- data/lib/clacky/server/web_ui_controller.rb +21 -2
- data/lib/clacky/session_manager.rb +43 -73
- data/lib/clacky/skill.rb +64 -42
- data/lib/clacky/tools/browser.rb +728 -183
- data/lib/clacky/tools/invoke_skill.rb +18 -11
- data/lib/clacky/tools/shell.rb +16 -7
- data/lib/clacky/tools/web_fetch.rb +3 -1
- data/lib/clacky/ui2/README.md +1 -1
- data/lib/clacky/ui2/components/input_area.rb +5 -2
- data/lib/clacky/ui2/components/welcome_banner.rb +23 -0
- data/lib/clacky/ui2/ui_controller.rb +16 -2
- data/lib/clacky/ui_interface.rb +7 -0
- data/lib/clacky/utils/file_processor.rb +116 -139
- data/lib/clacky/utils/model_pricing.rb +5 -4
- data/lib/clacky/utils/parser_manager.rb +93 -0
- data/lib/clacky/utils/workspace_rules.rb +46 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +696 -62
- data/lib/clacky/web/app.js +181 -51
- data/lib/clacky/web/brand.js +14 -0
- data/lib/clacky/web/channels.js +10 -1
- data/lib/clacky/web/i18n.js +49 -7
- data/lib/clacky/web/index.html +67 -10
- data/lib/clacky/web/onboard.js +2 -1
- data/lib/clacky/web/sessions.js +255 -69
- data/lib/clacky/web/settings.js +113 -1
- data/lib/clacky/web/skills.js +16 -3
- data/lib/clacky/web/tasks.js +11 -6
- data/lib/clacky/web/weixin-qr.html +104 -0
- data/lib/clacky.rb +2 -0
- data/scripts/install.sh +41 -11
- metadata +48 -7
- data/lib/clacky/default_skills/pdf-reader/SKILL.md +0 -90
- data/lib/clacky/utils/file_parser/docx_parser.rb +0 -156
- data/lib/clacky/utils/file_parser/pptx_parser.rb +0 -116
- data/lib/clacky/utils/file_parser/xlsx_parser.rb +0 -95
- data/lib/clacky/utils/file_parser/zip_parser.rb +0 -60
- data/scripts/install_agent_browser.sh +0 -67
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# publish.rb — Publish or delete a profile card on the platform
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# ruby publish.rb publish --name "NAME" --html-file /path/to/card.html
|
|
6
|
+
# ruby publish.rb delete --slug SLUG
|
|
7
|
+
#
|
|
8
|
+
# On first publish, prints the card URL and saves the update token to
|
|
9
|
+
# ~/.clacky/card_token (used for future updates/deletes).
|
|
10
|
+
#
|
|
11
|
+
# Environment:
|
|
12
|
+
# CARD_API_BASE — platform base URL (default: https://app.clacky.ai)
|
|
13
|
+
# CARD_HMAC_SECRET — shared secret (default matches platform default)
|
|
14
|
+
|
|
15
|
+
require "net/http"
|
|
16
|
+
require "uri"
|
|
17
|
+
require "json"
|
|
18
|
+
require "openssl"
|
|
19
|
+
require "digest"
|
|
20
|
+
require "optparse"
|
|
21
|
+
require "fileutils"
|
|
22
|
+
|
|
23
|
+
# ── Config ───────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
API_BASE = ENV.fetch("CLACKY_LICENSE_SERVER", "https://www.openclacky.com")
|
|
26
|
+
HMAC_SECRET = ENV.fetch("CARD_HMAC_SECRET", "openclacky-card-v1-default-secret-change-me")
|
|
27
|
+
TOKEN_FILE = File.expand_path("~/clacky_workspace/personal_website/token.json")
|
|
28
|
+
|
|
29
|
+
# ── HMAC signing ─────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
def device_fingerprint
|
|
32
|
+
# Combine stable machine identifiers into an opaque hash
|
|
33
|
+
parts = []
|
|
34
|
+
parts << `hostname`.strip
|
|
35
|
+
# macOS: hardware UUID
|
|
36
|
+
hw = `system_profiler SPHardwareDataType 2>/dev/null | grep 'Hardware UUID'`.strip
|
|
37
|
+
parts << hw unless hw.empty?
|
|
38
|
+
# Fallback: username
|
|
39
|
+
parts << ENV["USER"].to_s
|
|
40
|
+
Digest::SHA256.hexdigest(parts.join("|"))[0, 16]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def hmac_headers
|
|
44
|
+
ts = Time.now.to_i.to_s
|
|
45
|
+
fingerprint = device_fingerprint
|
|
46
|
+
payload = "openclacky:#{ts}:#{fingerprint}"
|
|
47
|
+
signature = OpenSSL::HMAC.hexdigest("SHA256", HMAC_SECRET, payload)
|
|
48
|
+
{
|
|
49
|
+
"X-Card-Timestamp" => ts,
|
|
50
|
+
"X-Card-Fingerprint" => fingerprint,
|
|
51
|
+
"X-Card-Signature" => signature,
|
|
52
|
+
"Content-Type" => "application/json"
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# ── HTTP helpers ──────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
def http_request(method, path, body: nil)
|
|
59
|
+
uri = URI.parse("#{API_BASE}#{path}")
|
|
60
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
61
|
+
http.use_ssl = uri.scheme == "https"
|
|
62
|
+
http.read_timeout = 15
|
|
63
|
+
http.open_timeout = 10
|
|
64
|
+
|
|
65
|
+
req_class = { "POST" => Net::HTTP::Post, "PATCH" => Net::HTTP::Patch,
|
|
66
|
+
"DELETE" => Net::HTTP::Delete }[method]
|
|
67
|
+
req = req_class.new(uri.path)
|
|
68
|
+
hmac_headers.each { |k, v| req[k] = v }
|
|
69
|
+
req.body = body.to_json if body
|
|
70
|
+
|
|
71
|
+
response = http.request(req)
|
|
72
|
+
parsed = JSON.parse(response.body) rescue { "raw" => response.body }
|
|
73
|
+
[response.code.to_i, parsed]
|
|
74
|
+
rescue => e
|
|
75
|
+
warn "❌ Network error: #{e.message}"
|
|
76
|
+
exit 1
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# ── Token storage ─────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
def load_token_data
|
|
82
|
+
return {} unless File.exist?(TOKEN_FILE)
|
|
83
|
+
JSON.parse(File.read(TOKEN_FILE)) rescue {}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def save_token_data(data)
|
|
87
|
+
FileUtils.mkdir_p(File.dirname(TOKEN_FILE))
|
|
88
|
+
File.write(TOKEN_FILE, JSON.pretty_generate(data))
|
|
89
|
+
File.chmod(0600, TOKEN_FILE)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# ── Commands ──────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
def cmd_publish(name:, html_file:)
|
|
95
|
+
unless File.exist?(html_file)
|
|
96
|
+
warn "❌ HTML file not found: #{html_file}"
|
|
97
|
+
exit 1
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
html_content = File.read(html_file, encoding: "utf-8")
|
|
101
|
+
|
|
102
|
+
if html_content.bytesize > 1_048_576
|
|
103
|
+
warn "❌ HTML file exceeds 1MB (#{html_content.bytesize / 1024}KB)"
|
|
104
|
+
exit 1
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
token_data = load_token_data
|
|
108
|
+
|
|
109
|
+
# If we already have a slug + token, do an update (PATCH) instead of create
|
|
110
|
+
if token_data["slug"] && token_data["update_token"]
|
|
111
|
+
slug = token_data["slug"]
|
|
112
|
+
token = token_data["update_token"]
|
|
113
|
+
|
|
114
|
+
uri = URI.parse("#{API_BASE}/api/v1/personal_websites/#{slug}")
|
|
115
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
116
|
+
http.use_ssl = uri.scheme == "https"
|
|
117
|
+
req = Net::HTTP::Patch.new(uri.path)
|
|
118
|
+
hmac_headers.each { |k, v| req[k] = v }
|
|
119
|
+
req["X-Card-Token"] = token
|
|
120
|
+
req.body = { html_content: html_content }.to_json
|
|
121
|
+
resp = http.request(req)
|
|
122
|
+
status = resp.code.to_i
|
|
123
|
+
body = JSON.parse(resp.body) rescue { "raw" => resp.body }
|
|
124
|
+
|
|
125
|
+
if status == 200
|
|
126
|
+
puts "✅ Website updated: #{body["url"]}"
|
|
127
|
+
else
|
|
128
|
+
warn "❌ Update failed (#{status}): #{body["error"] || body.inspect}"
|
|
129
|
+
exit 1
|
|
130
|
+
end
|
|
131
|
+
else
|
|
132
|
+
# First publish — POST to create
|
|
133
|
+
status, body = http_request("POST", "/api/v1/personal_websites",
|
|
134
|
+
body: { name: name, html_content: html_content })
|
|
135
|
+
|
|
136
|
+
if status == 201
|
|
137
|
+
save_token_data("slug" => body["slug"], "update_token" => body["update_token"])
|
|
138
|
+
puts "✅ Website published: #{body["url"]}"
|
|
139
|
+
puts " Slug: #{body["slug"]}"
|
|
140
|
+
puts " Token saved to: #{TOKEN_FILE}"
|
|
141
|
+
else
|
|
142
|
+
warn "❌ Publish failed (#{status}): #{body["error"] || body.inspect}"
|
|
143
|
+
exit 1
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def cmd_delete(slug: nil)
|
|
149
|
+
token_data = load_token_data
|
|
150
|
+
token = token_data["update_token"]
|
|
151
|
+
slug = slug || token_data["slug"]
|
|
152
|
+
|
|
153
|
+
unless token && slug
|
|
154
|
+
warn "❌ No published website found (#{TOKEN_FILE} missing or incomplete)."
|
|
155
|
+
warn " Nothing to delete."
|
|
156
|
+
exit 1
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
uri = URI.parse("#{API_BASE}/api/v1/personal_websites/#{slug}")
|
|
160
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
161
|
+
http.use_ssl = uri.scheme == "https"
|
|
162
|
+
req = Net::HTTP::Delete.new(uri.path)
|
|
163
|
+
hmac_headers.each { |k, v| req[k] = v }
|
|
164
|
+
req["X-Card-Token"] = token
|
|
165
|
+
|
|
166
|
+
resp = http.request(req)
|
|
167
|
+
status = resp.code.to_i
|
|
168
|
+
body = JSON.parse(resp.body) rescue { "raw" => resp.body }
|
|
169
|
+
|
|
170
|
+
if status == 200
|
|
171
|
+
File.delete(TOKEN_FILE) if File.exist?(TOKEN_FILE)
|
|
172
|
+
puts "✅ Personal website deleted: /~#{slug}"
|
|
173
|
+
else
|
|
174
|
+
warn "❌ Delete failed (#{status}): #{body["error"] || body.inspect}"
|
|
175
|
+
exit 1
|
|
176
|
+
end
|
|
177
|
+
rescue => e
|
|
178
|
+
warn "❌ Network error: #{e.message}"
|
|
179
|
+
exit 1
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# ── CLI parsing ───────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
command = ARGV.shift
|
|
185
|
+
|
|
186
|
+
case command
|
|
187
|
+
when "publish"
|
|
188
|
+
options = {}
|
|
189
|
+
OptionParser.new do |opts|
|
|
190
|
+
opts.on("--name NAME") { |v| options[:name] = v }
|
|
191
|
+
opts.on("--html-file FILE") { |v| options[:html_file] = v }
|
|
192
|
+
end.parse!
|
|
193
|
+
|
|
194
|
+
unless options[:name] && options[:html_file]
|
|
195
|
+
warn "Usage: ruby publish.rb publish --name NAME --html-file FILE"
|
|
196
|
+
exit 1
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
cmd_publish(name: options[:name], html_file: File.expand_path(options[:html_file]))
|
|
200
|
+
|
|
201
|
+
when "delete"
|
|
202
|
+
options = {}
|
|
203
|
+
OptionParser.new do |opts|
|
|
204
|
+
opts.on("--slug SLUG") { |v| options[:slug] = v } # optional, auto-read from token file
|
|
205
|
+
end.parse!
|
|
206
|
+
|
|
207
|
+
cmd_delete(slug: options[:slug])
|
|
208
|
+
|
|
209
|
+
else
|
|
210
|
+
warn "Usage: ruby publish.rb publish|delete [options]"
|
|
211
|
+
warn " publish --name NAME --html-file FILE"
|
|
212
|
+
warn " delete --slug SLUG"
|
|
213
|
+
exit 1
|
|
214
|
+
end
|
|
@@ -11,12 +11,12 @@ Installs a skill from a zip URL **or a local zip file path** using the bundled `
|
|
|
11
11
|
|
|
12
12
|
## How to Install
|
|
13
13
|
|
|
14
|
-
The script path is
|
|
14
|
+
The script path is listed in the Supporting Files above (`scripts/install_from_zip.rb`) — use the full path directly, no `find` needed.
|
|
15
15
|
|
|
16
16
|
The script accepts **either a remote URL or a local file path**:
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
|
-
ruby "
|
|
19
|
+
ruby "SKILL_DIR/scripts/install_from_zip.rb" <zip_url_or_path> [slug]
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
- `<zip_url_or_path>` — a remote `https://` URL **or** an absolute/relative local path to a `.zip` file
|
|
@@ -26,7 +26,7 @@ The script handles everything automatically:
|
|
|
26
26
|
- For URLs: downloads the zip (follows HTTP redirects)
|
|
27
27
|
- For local paths: reads the file directly (no download needed)
|
|
28
28
|
- Extracts and locates all `SKILL.md` files inside
|
|
29
|
-
- Copies skill directories to
|
|
29
|
+
- Copies skill directories to `~/.clacky/skills/`
|
|
30
30
|
- Reports installed skills with their descriptions
|
|
31
31
|
|
|
32
32
|
**Do NOT manually download or unzip — the script handles everything.**
|
|
@@ -38,7 +38,7 @@ The script handles everything automatically:
|
|
|
38
38
|
/skill-add https://store.clacky.ai/skills/canvas-design-1.2.0.zip
|
|
39
39
|
```
|
|
40
40
|
```bash
|
|
41
|
-
ruby "
|
|
41
|
+
ruby "SKILL_DIR/scripts/install_from_zip.rb" \
|
|
42
42
|
"https://store.clacky.ai/skills/canvas-design-1.2.0.zip" \
|
|
43
43
|
"canvas-design"
|
|
44
44
|
```
|
|
@@ -48,13 +48,12 @@ ruby "$SKILL_DIR/scripts/install_from_zip.rb" \
|
|
|
48
48
|
/skill-add /Users/alice/Downloads/my-skill-1.0.0.zip
|
|
49
49
|
```
|
|
50
50
|
```bash
|
|
51
|
-
ruby "
|
|
51
|
+
ruby "SKILL_DIR/scripts/install_from_zip.rb" \
|
|
52
52
|
"/Users/alice/Downloads/my-skill-1.0.0.zip"
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
## Notes
|
|
56
56
|
|
|
57
|
-
- Skills install to
|
|
58
|
-
- Project-level skills override global skills (`~/.clacky/skills/`)
|
|
57
|
+
- Skills install to `~/.clacky/skills/` by default
|
|
59
58
|
- Local paths may be absolute (`/path/to/skill.zip`) or use `~` (`~/Downloads/skill.zip`)
|
|
60
59
|
- If the user doesn't provide a URL or path, ask them for the zip source
|
|
@@ -26,7 +26,7 @@ class ZipSkillInstaller
|
|
|
26
26
|
# If not provided, we try to infer it from the filename in the URL/path, e.g.
|
|
27
27
|
# "ui-ux-pro-max-1.0.0.zip" → "ui-ux-pro-max".
|
|
28
28
|
@skill_name = skill_name || infer_skill_name(zip_source)
|
|
29
|
-
@target_dir = target_dir || File.join(Dir.
|
|
29
|
+
@target_dir = target_dir || File.join(Dir.home, '.clacky', 'skills')
|
|
30
30
|
@installed_skills = []
|
|
31
31
|
@errors = []
|
|
32
32
|
end
|
|
@@ -151,13 +151,13 @@ end
|
|
|
151
151
|
puts result # stdout is the output
|
|
152
152
|
```
|
|
153
153
|
|
|
154
|
-
Invoke from SKILL.md
|
|
154
|
+
Invoke from SKILL.md by referencing the script via the Supporting Files block — at runtime, the AI receives the full absolute path of every supporting file. Refer to it as `SKILL_DIR` in instructions so the AI substitutes the correct path from the Supporting Files list:
|
|
155
155
|
|
|
156
156
|
```bash
|
|
157
|
-
ruby "
|
|
157
|
+
ruby "SKILL_DIR/scripts/do_something.rb" "argument"
|
|
158
158
|
```
|
|
159
159
|
|
|
160
|
-
Never hardcode paths like `~/.clacky/skills/my-skill/scripts/...` — they break when the skill is installed at a different location. Never use `find` to locate scripts —
|
|
160
|
+
Never hardcode paths like `~/.clacky/skills/my-skill/scripts/...` — they break when the skill is installed at a different location. Never use `find` to locate scripts — the Supporting Files block always provides the correct absolute paths.
|
|
161
161
|
|
|
162
162
|
Ruby standard library covers most needs (`net/http`, `json`, `fileutils`, `uri`, `time`). No gems needed for basic API calls.
|
|
163
163
|
|
|
@@ -97,6 +97,10 @@ module Clacky
|
|
|
97
97
|
emit("info", message: message)
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
+
def show_idle_status(phase:, message:)
|
|
101
|
+
emit("idle_status", phase: phase.to_s, message: message)
|
|
102
|
+
end
|
|
103
|
+
|
|
100
104
|
def show_warning(message)
|
|
101
105
|
emit("warning", message: message)
|
|
102
106
|
end
|
|
@@ -189,5 +193,6 @@ module Clacky
|
|
|
189
193
|
def stop
|
|
190
194
|
# no-op
|
|
191
195
|
end
|
|
196
|
+
|
|
192
197
|
end
|
|
193
198
|
end
|
|
@@ -99,10 +99,16 @@ module Clacky
|
|
|
99
99
|
|
|
100
100
|
response[:tool_calls].map do |tc|
|
|
101
101
|
result = results_map[tc[:id]]
|
|
102
|
+
raw_content = result ? result[:content] : { error: "Tool result missing" }.to_json
|
|
103
|
+
|
|
104
|
+
# OpenAI tool message content must be a String.
|
|
105
|
+
# If a tool returned multipart Array blocks (e.g. screenshot image), convert to JSON.
|
|
106
|
+
content = raw_content.is_a?(Array) ? JSON.generate(raw_content) : raw_content
|
|
107
|
+
|
|
102
108
|
{
|
|
103
109
|
role: "tool",
|
|
104
110
|
tool_call_id: tc[:id],
|
|
105
|
-
content:
|
|
111
|
+
content: content
|
|
106
112
|
}
|
|
107
113
|
end
|
|
108
114
|
end
|
|
@@ -87,6 +87,11 @@ module Clacky
|
|
|
87
87
|
puts_line("[info] #{message}")
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
+
def show_idle_status(phase:, message:)
|
|
91
|
+
# In plain mode, just print the final state
|
|
92
|
+
puts_line("[info] #{message}") if phase.to_s == "end"
|
|
93
|
+
end
|
|
94
|
+
|
|
90
95
|
def show_warning(message)
|
|
91
96
|
puts_line("[warn] #{message}")
|
|
92
97
|
end
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Clacky
|
|
6
|
+
# BrowserManager owns the chrome-devtools-mcp daemon lifecycle.
|
|
7
|
+
#
|
|
8
|
+
# It mirrors the ChannelManager pattern:
|
|
9
|
+
# - start → read browser.yml; if enabled, pre-warm the MCP daemon
|
|
10
|
+
# - stop → kill the daemon
|
|
11
|
+
# - reload → stop + re-read yml + start (called after browser-setup writes yml)
|
|
12
|
+
# - status → { enabled: bool, daemon_running: bool, chrome_version: String|nil }
|
|
13
|
+
# - toggle → flip enabled in browser.yml and reload
|
|
14
|
+
#
|
|
15
|
+
# browser.yml schema:
|
|
16
|
+
# enabled: true/false — whether the browser tool is active
|
|
17
|
+
# chrome_version: "146" — detected Chrome version (set by browser-setup skill)
|
|
18
|
+
# configured_at: date — when setup was last run
|
|
19
|
+
#
|
|
20
|
+
# Liveness check strategy:
|
|
21
|
+
# process_alive? sends an MCP `ping` (standard in MCP spec 2024-11-05) and
|
|
22
|
+
# waits up to 3s for a response. If the ping succeeds the daemon is healthy.
|
|
23
|
+
# If it times out or raises an IO error the daemon is truly dead — kill it so
|
|
24
|
+
# ensure_process! will spawn a fresh one on the next call.
|
|
25
|
+
#
|
|
26
|
+
# Chrome connection problems (e.g. Chrome closed) surface only during the
|
|
27
|
+
# actual mcp_call and are reported back to the caller; they do NOT trigger a
|
|
28
|
+
# daemon restart.
|
|
29
|
+
#
|
|
30
|
+
# Browser tool (browser.rb) delegates daemon access here instead of using
|
|
31
|
+
# class-level @@mcp_process variables directly. BrowserManager holds the
|
|
32
|
+
# single mutable state; the mutex lives here too.
|
|
33
|
+
class BrowserManager
|
|
34
|
+
BROWSER_CONFIG_PATH = File.expand_path("~/.clacky/browser.yml").freeze
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
def instance
|
|
38
|
+
@instance ||= new
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def initialize
|
|
43
|
+
@process = nil # { stdin:, stdout:, pid:, wait_thr: }
|
|
44
|
+
@mutex = Mutex.new
|
|
45
|
+
@call_id = 2 # 1 reserved for MCP initialize handshake
|
|
46
|
+
@config = {} # last successfully read browser.yml content
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Lifecycle
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
# Start the daemon if browser.yml marks the browser as enabled.
|
|
54
|
+
# Non-blocking — returns immediately (daemon spawn takes ~200ms in background).
|
|
55
|
+
def start
|
|
56
|
+
cfg = load_config
|
|
57
|
+
unless cfg["enabled"] == true
|
|
58
|
+
Clacky::Logger.info("[BrowserManager] Not enabled — skipping daemon start")
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@config = cfg
|
|
63
|
+
Clacky::Logger.info("[BrowserManager] Browser enabled, pre-warming MCP daemon...")
|
|
64
|
+
Thread.new do
|
|
65
|
+
Thread.current.name = "browser-manager-start"
|
|
66
|
+
@mutex.synchronize { ensure_process! }
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
Clacky::Logger.warn("[BrowserManager] Pre-warm failed: #{e.message}")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Stop and clean up the daemon.
|
|
73
|
+
def stop
|
|
74
|
+
@mutex.synchronize { kill_process! }
|
|
75
|
+
Clacky::Logger.info("[BrowserManager] Daemon stopped")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Hot-reload: stop existing daemon, re-read yml, restart if enabled.
|
|
79
|
+
# Called by HttpServer after browser-setup writes a new browser.yml.
|
|
80
|
+
def reload
|
|
81
|
+
Clacky::Logger.info("[BrowserManager] Reloading...")
|
|
82
|
+
@mutex.synchronize { kill_process! }
|
|
83
|
+
|
|
84
|
+
cfg = load_config
|
|
85
|
+
@config = cfg
|
|
86
|
+
|
|
87
|
+
if cfg["enabled"] == true
|
|
88
|
+
Clacky::Logger.info("[BrowserManager] Browser enabled, restarting daemon")
|
|
89
|
+
Thread.new do
|
|
90
|
+
Thread.current.name = "browser-manager-reload"
|
|
91
|
+
@mutex.synchronize { ensure_process! }
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
Clacky::Logger.warn("[BrowserManager] Reload start failed: #{e.message}")
|
|
94
|
+
end
|
|
95
|
+
else
|
|
96
|
+
Clacky::Logger.info("[BrowserManager] Browser disabled after reload — daemon not started")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Returns a status hash with real daemon liveness.
|
|
101
|
+
# @return [Hash] { enabled: bool, daemon_running: bool, chrome_version: String|nil }
|
|
102
|
+
def status
|
|
103
|
+
cfg = load_config
|
|
104
|
+
enabled = cfg["enabled"] == true
|
|
105
|
+
running = @mutex.synchronize { process_alive? }
|
|
106
|
+
{
|
|
107
|
+
enabled: enabled,
|
|
108
|
+
daemon_running: running,
|
|
109
|
+
chrome_version: cfg["chrome_version"]
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Write browser.yml with the given config and reload the daemon.
|
|
114
|
+
# Called by HttpServer POST /api/browser/configure.
|
|
115
|
+
# @param chrome_version [String] detected Chrome major version
|
|
116
|
+
def configure(chrome_version:)
|
|
117
|
+
cfg = {
|
|
118
|
+
"enabled" => true,
|
|
119
|
+
"browser" => "chrome",
|
|
120
|
+
"chrome_version" => chrome_version.to_s,
|
|
121
|
+
"configured_at" => Date.today.to_s
|
|
122
|
+
}
|
|
123
|
+
FileUtils.mkdir_p(File.dirname(BROWSER_CONFIG_PATH))
|
|
124
|
+
File.write(BROWSER_CONFIG_PATH, cfg.to_yaml)
|
|
125
|
+
reload
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Toggle the browser tool on/off by flipping `enabled` in browser.yml.
|
|
129
|
+
# Raises if browser.yml doesn't exist (not yet set up).
|
|
130
|
+
# @return [Boolean] new enabled state
|
|
131
|
+
def toggle
|
|
132
|
+
raise "Browser not configured. Run /browser-setup first." unless File.exist?(BROWSER_CONFIG_PATH)
|
|
133
|
+
|
|
134
|
+
cfg = load_config
|
|
135
|
+
new_enabled = !(cfg["enabled"] == true)
|
|
136
|
+
cfg["enabled"] = new_enabled
|
|
137
|
+
File.write(BROWSER_CONFIG_PATH, cfg.to_yaml)
|
|
138
|
+
@config = cfg
|
|
139
|
+
reload
|
|
140
|
+
new_enabled
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# MCP call interface — used by Browser tool
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
# Execute a chrome-devtools-mcp tool call. Ensures daemon is running first.
|
|
148
|
+
# Thread-safe via @mutex.
|
|
149
|
+
# @param tool_name [String]
|
|
150
|
+
# @param arguments [Hash]
|
|
151
|
+
# @return [Hash] parsed MCP result
|
|
152
|
+
# @raise [RuntimeError] on timeout or protocol error
|
|
153
|
+
def mcp_call(tool_name, arguments = {})
|
|
154
|
+
call_resp = nil
|
|
155
|
+
|
|
156
|
+
@mutex.synchronize do
|
|
157
|
+
ensure_process!
|
|
158
|
+
|
|
159
|
+
call_id = @call_id
|
|
160
|
+
@call_id += 1
|
|
161
|
+
|
|
162
|
+
msg = json_rpc("tools/call", { name: tool_name, arguments: arguments }, id: call_id)
|
|
163
|
+
@process[:stdin].write(msg + "\n")
|
|
164
|
+
@process[:stdin].flush
|
|
165
|
+
|
|
166
|
+
call_resp = read_response(@process[:stdout], target_id: call_id,
|
|
167
|
+
timeout: Clacky::Tools::Browser::MCP_CALL_TIMEOUT)
|
|
168
|
+
|
|
169
|
+
unless call_resp
|
|
170
|
+
kill_process!
|
|
171
|
+
raise "Chrome MCP tools/call '#{tool_name}' timed out after #{Clacky::Tools::Browser::MCP_CALL_TIMEOUT}s"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
if call_resp["error"]
|
|
175
|
+
err = call_resp["error"]
|
|
176
|
+
raise "Chrome MCP error: #{err.is_a?(Hash) ? err["message"] : err}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
result = call_resp["result"] || {}
|
|
180
|
+
|
|
181
|
+
if result["isError"]
|
|
182
|
+
text = extract_text_content(result)
|
|
183
|
+
raise text.empty? ? "Chrome MCP tool '#{tool_name}' failed" : text
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
result
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Private
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
def load_config
|
|
196
|
+
return {} unless File.exist?(BROWSER_CONFIG_PATH)
|
|
197
|
+
YAML.safe_load(File.read(BROWSER_CONFIG_PATH), permitted_classes: [Date, Time, Symbol]) || {}
|
|
198
|
+
rescue StandardError => e
|
|
199
|
+
Clacky::Logger.warn("[BrowserManager] Failed to read browser.yml: #{e.message}")
|
|
200
|
+
{}
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Must be called inside @mutex
|
|
204
|
+
def ensure_process!
|
|
205
|
+
return if process_alive?
|
|
206
|
+
|
|
207
|
+
cmd = Clacky::Tools::Browser.build_mcp_command
|
|
208
|
+
stdin, stdout, stderr_io, wait_thr = Open3.popen3(*cmd)
|
|
209
|
+
Thread.new { stderr_io.read rescue nil }
|
|
210
|
+
|
|
211
|
+
# MCP handshake
|
|
212
|
+
init_msg = json_rpc("initialize", {
|
|
213
|
+
protocolVersion: "2024-11-05",
|
|
214
|
+
capabilities: {},
|
|
215
|
+
clientInfo: { name: "clacky", version: "1.0" }
|
|
216
|
+
}, id: 1)
|
|
217
|
+
|
|
218
|
+
notify_msg = JSON.generate({
|
|
219
|
+
jsonrpc: "2.0",
|
|
220
|
+
method: "notifications/initialized",
|
|
221
|
+
params: {}
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
stdin.write(init_msg + "\n")
|
|
225
|
+
stdin.flush
|
|
226
|
+
|
|
227
|
+
init_resp = read_response(stdout, target_id: 1,
|
|
228
|
+
timeout: Clacky::Tools::Browser::MCP_HANDSHAKE_TIMEOUT)
|
|
229
|
+
unless init_resp
|
|
230
|
+
Process.kill("TERM", wait_thr.pid) rescue nil
|
|
231
|
+
raise "Chrome MCP initialize handshake timed out"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
stdin.write(notify_msg + "\n")
|
|
235
|
+
stdin.flush
|
|
236
|
+
|
|
237
|
+
@process = { stdin: stdin, stdout: stdout, pid: wait_thr.pid, wait_thr: wait_thr }
|
|
238
|
+
@call_id = 2
|
|
239
|
+
Clacky::Logger.info("[BrowserManager] MCP daemon started (pid=#{wait_thr.pid})")
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Must be called inside @mutex.
|
|
243
|
+
# Sends an MCP `ping` (MCP spec 2024-11-05) and waits up to 3s for a pong.
|
|
244
|
+
# A successful ping means the MCP daemon process is alive and responsive.
|
|
245
|
+
# Chrome connection health is NOT checked here — Chrome problems surface only
|
|
246
|
+
# during the actual mcp_call and are reported back to the caller.
|
|
247
|
+
def process_alive?
|
|
248
|
+
return false if @process.nil?
|
|
249
|
+
|
|
250
|
+
ping_id = 0
|
|
251
|
+
msg = json_rpc("ping", {}, id: ping_id)
|
|
252
|
+
@process[:stdin].write(msg + "\n")
|
|
253
|
+
@process[:stdin].flush
|
|
254
|
+
resp = read_response(@process[:stdout], target_id: ping_id, timeout: 3)
|
|
255
|
+
if resp.nil?
|
|
256
|
+
kill_process!
|
|
257
|
+
return false
|
|
258
|
+
end
|
|
259
|
+
true
|
|
260
|
+
rescue StandardError
|
|
261
|
+
kill_process!
|
|
262
|
+
false
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Must be called inside @mutex
|
|
266
|
+
def kill_process!
|
|
267
|
+
ps = @process
|
|
268
|
+
return unless ps
|
|
269
|
+
|
|
270
|
+
Process.kill("TERM", ps[:pid]) rescue nil
|
|
271
|
+
ps[:stdin].close rescue nil
|
|
272
|
+
ps[:stdout].close rescue nil
|
|
273
|
+
@process = nil
|
|
274
|
+
Clacky::Logger.info("[BrowserManager] MCP daemon killed")
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def json_rpc(method, params, id:)
|
|
278
|
+
JSON.generate({ jsonrpc: "2.0", id: id, method: method, params: params })
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def read_response(io, target_id:, timeout: 10)
|
|
282
|
+
Timeout.timeout(timeout) do
|
|
283
|
+
loop do
|
|
284
|
+
line = io.gets
|
|
285
|
+
break if line.nil?
|
|
286
|
+
line = line.strip
|
|
287
|
+
next if line.empty?
|
|
288
|
+
begin
|
|
289
|
+
msg = JSON.parse(line)
|
|
290
|
+
return msg if msg.is_a?(Hash) && msg["id"] == target_id
|
|
291
|
+
rescue JSON::ParserError
|
|
292
|
+
next
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
nil
|
|
296
|
+
end
|
|
297
|
+
rescue Timeout::Error
|
|
298
|
+
nil
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def extract_text_content(result)
|
|
302
|
+
Array(result["content"])
|
|
303
|
+
.select { |b| b.is_a?(Hash) && b["type"] == "text" }
|
|
304
|
+
.map { |b| b["text"].to_s }
|
|
305
|
+
.join("\n")
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|