openclacky 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/lib/clacky/agent/llm_caller.rb +403 -0
  4. data/lib/clacky/agent/message_compressor.rb +15 -4
  5. data/lib/clacky/agent/message_compressor_helper.rb +41 -2
  6. data/lib/clacky/agent/tool_registry.rb +109 -0
  7. data/lib/clacky/agent.rb +69 -2
  8. data/lib/clacky/agent_config.rb +17 -0
  9. data/lib/clacky/cli.rb +65 -0
  10. data/lib/clacky/default_skills/channel-setup/SKILL.md +57 -3
  11. data/lib/clacky/default_skills/onboard/SKILL.md +14 -5
  12. data/lib/clacky/default_skills/onboard/scripts/install_builtin_skills.rb +175 -0
  13. data/lib/clacky/default_skills/skill-add/scripts/install_from_zip.rb +59 -26
  14. data/lib/clacky/providers.rb +57 -3
  15. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +14 -0
  16. data/lib/clacky/server/channel/adapters/feishu/bot.rb +10 -0
  17. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +1 -0
  18. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +7 -0
  19. data/lib/clacky/server/channel/channel_manager.rb +103 -4
  20. data/lib/clacky/server/channel/channel_ui_controller.rb +8 -2
  21. data/lib/clacky/server/discover.rb +77 -0
  22. data/lib/clacky/server/epipe_safe_io.rb +105 -0
  23. data/lib/clacky/server/http_server.rb +90 -46
  24. data/lib/clacky/server/server_master.rb +6 -0
  25. data/lib/clacky/skill.rb +30 -0
  26. data/lib/clacky/utils/file_processor.rb +14 -40
  27. data/lib/clacky/utils/model_pricing.rb +95 -0
  28. data/lib/clacky/version.rb +1 -1
  29. data/lib/clacky/web/app.css +157 -31
  30. data/lib/clacky/web/i18n.js +18 -2
  31. data/lib/clacky/web/index.html +8 -2
  32. data/lib/clacky/web/onboard.js +77 -1
  33. data/lib/clacky/web/sessions.js +31 -19
  34. data/lib/clacky/web/settings.js +127 -6
  35. data/lib/clacky/web/skills.js +4 -0
  36. data/lib/clacky.rb +5 -0
  37. metadata +5 -2
@@ -2,18 +2,127 @@
2
2
 
3
3
  module Clacky
4
4
  class ToolRegistry
5
+ # Common aliases that LLMs frequently use instead of the registered tool names.
6
+ # Keys are downcased aliases; values are the canonical registered names.
7
+ TOOL_ALIASES = {
8
+ # file_reader aliases
9
+ "read" => "file_reader",
10
+ "read_file" => "file_reader",
11
+ "filereader" => "file_reader",
12
+ "file_read" => "file_reader",
13
+ "cat" => "file_reader",
14
+ # write aliases
15
+ "write_file" => "write",
16
+ "create_file" => "write",
17
+ "file_write" => "write",
18
+ # edit aliases
19
+ "file_edit" => "edit",
20
+ "replace" => "edit",
21
+ "replace_in_file" => "edit",
22
+ "str_replace" => "edit",
23
+ # terminal aliases
24
+ "shell" => "terminal",
25
+ "bash" => "terminal",
26
+ "exec" => "terminal",
27
+ "execute" => "terminal",
28
+ "run_command" => "terminal",
29
+ "run" => "terminal",
30
+ "command" => "terminal",
31
+ # web_search aliases
32
+ "search" => "web_search",
33
+ "websearch" => "web_search",
34
+ "internet_search" => "web_search",
35
+ "online_search" => "web_search",
36
+ # web_fetch aliases
37
+ "fetch" => "web_fetch",
38
+ "webfetch" => "web_fetch",
39
+ "browse" => "web_fetch",
40
+ "url_fetch" => "web_fetch",
41
+ "http_get" => "web_fetch",
42
+ # grep aliases
43
+ "search_files" => "grep",
44
+ "search_in_files" => "grep",
45
+ "find_in_files" => "grep",
46
+ "search_code" => "grep",
47
+ # glob aliases
48
+ "find_files" => "glob",
49
+ "list_files" => "glob",
50
+ "file_glob" => "glob",
51
+ "search_filenames" => "glob",
52
+ # invoke_skill aliases
53
+ "skill" => "invoke_skill",
54
+ "run_skill" => "invoke_skill",
55
+ # todo_manager aliases
56
+ "todo" => "todo_manager",
57
+ "task_manager" => "todo_manager",
58
+ # request_user_feedback aliases
59
+ "ask_user" => "request_user_feedback",
60
+ "user_feedback" => "request_user_feedback",
61
+ "ask" => "request_user_feedback",
62
+ # undo_task aliases
63
+ "undo" => "undo_task",
64
+ # redo_task aliases
65
+ "redo" => "redo_task",
66
+ # list_tasks aliases
67
+ "tasks" => "list_tasks",
68
+ "task_history" => "list_tasks",
69
+ # trash_manager aliases
70
+ "trash" => "trash_manager",
71
+ "delete" => "trash_manager",
72
+ "rm" => "trash_manager",
73
+ "remove" => "trash_manager",
74
+ }.freeze
75
+
5
76
  def initialize
6
77
  @tools = {}
78
+ # Downcased index for case-insensitive lookups
79
+ @downcased_index = {}
7
80
  end
8
81
 
9
82
  def register(tool)
10
83
  @tools[tool.name] = tool
84
+ @downcased_index[tool.name.downcase] = tool.name
11
85
  end
12
86
 
13
87
  def get(name)
14
88
  @tools[name] || raise(Clacky::ToolCallError, "Tool not found: #{name}")
15
89
  end
16
90
 
91
+ # Resolve a tool name (possibly misspelt or aliased) to the canonical
92
+ # registered name. Resolution order:
93
+ # 1. Exact match in the registry
94
+ # 2. Case-insensitive match (e.g. "Read" → "file_reader")
95
+ # 3. Alias lookup (e.g. "read_file" → "file_reader")
96
+ # Returns the canonical tool name, or nil if nothing matched.
97
+ def resolve(name)
98
+ return name if @tools.key?(name)
99
+
100
+ downcased = name.downcase
101
+
102
+ # Case-insensitive match
103
+ if @downcased_index.key?(downcased)
104
+ return @downcased_index[downcased]
105
+ end
106
+
107
+ # Alias lookup
108
+ if TOOL_ALIASES.key?(downcased)
109
+ return TOOL_ALIASES[downcased]
110
+ end
111
+
112
+ # Fuzzy: try underscore / hyphen normalisation (e.g. "file-reader" → "file_reader")
113
+ normalized = downcased.tr("-", "_")
114
+ if normalized != downcased
115
+ if @downcased_index.key?(normalized)
116
+ return @downcased_index[normalized]
117
+ end
118
+ if TOOL_ALIASES.key?(normalized)
119
+ return TOOL_ALIASES[normalized]
120
+ end
121
+ end
122
+
123
+ nil
124
+ end
125
+
17
126
  def all
18
127
  @tools.values
19
128
  end
data/lib/clacky/agent.rb CHANGED
@@ -210,6 +210,7 @@ module Clacky
210
210
  @start_time = Time.now
211
211
  @task_truncation_count = 0 # Reset truncation counter for each task
212
212
  @task_timeout_hint_injected = false # Reset read-timeout hint injection (see LlmCaller)
213
+ @task_upstream_truncation_hint_injected = false # Reset upstream-truncation hint injection (see LlmCaller)
213
214
  @task_cost_source = :estimated # Reset for new task
214
215
  # Note: Do NOT reset @previous_total_tokens here - it should maintain the value from the last iteration
215
216
  # across tasks to correctly calculate delta tokens in each iteration
@@ -373,8 +374,58 @@ module Clacky
373
374
  # Skip if compression happened (response is nil)
374
375
  next if response.nil?
375
376
 
376
- # Check if done (no more tool calls needed)
377
- if response[:finish_reason] == "stop" || response[:tool_calls].nil? || response[:tool_calls].empty?
377
+ # [DIAG] Only log when finish_reason=="stop" AND tool_calls non-empty —
378
+ # the suspicious combo that indicates an upstream-truncated tool_use
379
+ # response. Normal responses produce no log line here to avoid noise.
380
+ begin
381
+ tool_calls = response[:tool_calls] || []
382
+ if response[:finish_reason] == "stop" && !tool_calls.empty?
383
+ tc_summary = tool_calls.map do |c|
384
+ args_str = c[:arguments].is_a?(String) ? c[:arguments] : c[:arguments].to_s
385
+ {
386
+ name: c[:name].to_s,
387
+ args_len: args_str.length,
388
+ args_head: args_str[0, 120]
389
+ }
390
+ end
391
+ Clacky::Logger.warn("agent.think_response",
392
+ session_id: @session_id,
393
+ iteration: @iterations,
394
+ finish_reason: response[:finish_reason].to_s,
395
+ tool_calls_count: tool_calls.size,
396
+ tool_calls: tc_summary,
397
+ content_len: response[:content].to_s.length,
398
+ completion_tokens: response.dig(:token_usage, :completion_tokens),
399
+ ttft_ms: response.dig(:latency, :ttft_ms),
400
+ suspicious_truncation: true
401
+ )
402
+ end
403
+ rescue StandardError => e
404
+ Clacky::Logger.warn("agent.think_response.log_failed", error: e.message)
405
+ end
406
+
407
+ # Check if done (no more tool calls needed).
408
+ #
409
+ # Defensive rule: we ONLY exit on empty/missing tool_calls.
410
+ # We used to also short-circuit on finish_reason=="stop", but
411
+ # upstream routers (OpenRouter → Anthropic/Bedrock) can return the
412
+ # contradictory combo `finish_reason=="stop" + non-empty tool_calls
413
+ # with truncated args`, which caused the agent to silently treat a
414
+ # truncated response as "task complete". Truncation is now caught
415
+ # earlier by LlmCaller#detect_upstream_truncation! (which raises
416
+ # UpstreamTruncatedError → RetryableError); this branch stays as
417
+ # a belt-and-braces guard: if that detector ever misses a new
418
+ # truncation pattern, we still won't silently exit while the model
419
+ # is mid-tool_call.
420
+ if response[:tool_calls].nil? || response[:tool_calls].empty?
421
+ # [DIAG] Pin down exactly which sub-condition triggered the task exit.
422
+ Clacky::Logger.info("agent.loop_break_normal",
423
+ session_id: @session_id,
424
+ iteration: @iterations,
425
+ branch: (response[:tool_calls].nil? ? "tool_calls_nil" : "tool_calls_empty"),
426
+ finish_reason: response[:finish_reason].to_s,
427
+ tool_calls_count: (response[:tool_calls] || []).size
428
+ )
378
429
  if response[:content] && !response[:content].empty?
379
430
  emit_assistant_message(response[:content])
380
431
  end
@@ -717,6 +768,22 @@ module Clacky
717
768
  awaiting_feedback = false
718
769
 
719
770
  tool_calls.each_with_index do |call, index|
771
+ # Resolve tool name: handle case-insensitive and common alias mismatches
772
+ # from different LLM providers (e.g. "read" → "file_reader", "Read" → "file_reader")
773
+ original_name = call[:name]
774
+ resolved = @tool_registry.resolve(call[:name])
775
+ if resolved && resolved != call[:name]
776
+ @debug_logs << {
777
+ timestamp: Time.now.iso8601,
778
+ event: "tool_name_resolved",
779
+ original: original_name,
780
+ resolved: resolved
781
+ }
782
+ call = call.merge(name: resolved)
783
+ elsif resolved.nil?
784
+ # Tool truly not found — let the rescue below handle it with a clear message
785
+ end
786
+
720
787
  # Hook: before_tool_use
721
788
  hook_result = @hooks.trigger(:before_tool_use, call)
722
789
  if hook_result[:action] == :deny
@@ -426,6 +426,23 @@ module Clacky
426
426
  true
427
427
  end
428
428
 
429
+ # Switch to a model by its display name (fuzzy match, case-insensitive).
430
+ #
431
+ # @param name [String] the model name to search for (e.g. "gpt-5.3-codex")
432
+ # @return [Boolean] true if switched, false if name not found
433
+ def switch_model_by_name(name)
434
+ return false if name.nil? || name.to_s.strip.empty?
435
+
436
+ name_str = name.to_s.strip.downcase
437
+ index = @models.find_index { |m| m["model"].to_s.downcase == name_str }
438
+ return false if index.nil?
439
+
440
+ @current_model_id = @models[index]["id"]
441
+ @current_model_index = index
442
+
443
+ true
444
+ end
445
+
429
446
  # Set the **global** default model marker (`type: "default"`).
430
447
  #
431
448
  # This is separate from `switch_model_by_id`:
data/lib/clacky/cli.rb CHANGED
@@ -41,6 +41,7 @@ module Clacky
41
41
 
42
42
  Examples:
43
43
  $ clacky agent --mode=auto_approve --path /path/to/project
44
+ $ clacky agent --model gpt-5.3-codex -m "write a hello world script"
44
45
  LONGDESC
45
46
  option :mode, type: :string, default: "confirm_safes",
46
47
  desc: "Permission mode: auto_approve, confirm_safes, confirm_all"
@@ -56,6 +57,7 @@ module Clacky
56
57
  option :file, type: :array, aliases: "-f", desc: "File path(s) to attach (use with -m; supports images and documents)"
57
58
  option :image, type: :array, aliases: "-i", desc: "Image file path(s) to attach (alias for --file, kept for compatibility)"
58
59
  option :agent, type: :string, default: "coding", desc: "Agent profile to use: coding, general, or any custom profile name (default: coding)"
60
+ option :model, type: :string, desc: "Override the model to use (by name, e.g. gpt-5.3-codex or deepseek-v4-pro). Uses default model if not specified"
59
61
  option :help, type: :boolean, aliases: "-h", desc: "Show this help message"
60
62
  def agent
61
63
  # Handle help option
@@ -68,8 +70,25 @@ module Clacky
68
70
  # Fire-and-forget background thread; never blocks startup.
69
71
  Clacky::Telemetry.startup!
70
72
 
73
+ # ── Sibling server discovery ───────────────────────────────────────
74
+ # Bare-CLI mode does NOT boot an HTTP server, so skills that call
75
+ # back into /api/* (channels, browser, scheduler) normally can't work.
76
+ # If the user happens to have a Clacky server running on this machine
77
+ # (in another terminal or via `clacky server`), auto-wire CLACKY_SERVER_HOST
78
+ # / CLACKY_SERVER_PORT so those skills can reach it transparently.
79
+ discover_sibling_server!
80
+
71
81
  agent_config = Clacky::AgentConfig.load
72
82
 
83
+ # Override model if --model option is specified
84
+ if options[:model]
85
+ unless agent_config.switch_model_by_name(options[:model])
86
+ # During early startup @ui may not be ready; use simple error output
87
+ $stderr.puts "Error: model '#{options[:model]}' not found. Available: #{agent_config.model_names.join(', ')}"
88
+ exit 1
89
+ end
90
+ end
91
+
73
92
  # Handle session listing
74
93
  if options[:list]
75
94
  list_sessions
@@ -148,6 +167,36 @@ module Clacky
148
167
  end
149
168
 
150
169
  no_commands do
170
+ # Detect a sibling Clacky server running on this machine and expose its
171
+ # address to skills via ENV. Runs only in bare-CLI mode (where no server
172
+ # is booted by this process), and only when the user hasn't already set
173
+ # CLACKY_SERVER_HOST / CLACKY_SERVER_PORT explicitly.
174
+ #
175
+ # Why: skills like `channel-setup` and `browser-setup` call back into
176
+ # http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/*. In server
177
+ # mode those vars are injected by HTTPServer#start. In CLI mode they
178
+ # would be blank, so the skill templates expand to an unreachable URL.
179
+ #
180
+ # Discovery is best-effort and non-fatal: if nothing is found we stay
181
+ # silent and let the skill's own pre-flight check emit a friendly error.
182
+ private def discover_sibling_server!
183
+ return if ENV["CLACKY_SERVER_PORT"] && !ENV["CLACKY_SERVER_PORT"].strip.empty?
184
+
185
+ require_relative "server/discover"
186
+ info = Clacky::Server::Discover.find_local
187
+ return unless info
188
+
189
+ ENV["CLACKY_SERVER_HOST"] = info[:host]
190
+ ENV["CLACKY_SERVER_PORT"] = info[:port].to_s
191
+ Clacky::Logger.debug(
192
+ "[CLI] Discovered local server PID=#{info[:pid]} at " \
193
+ "#{info[:host]}:#{info[:port]} — CLACKY_SERVER_* exported."
194
+ )
195
+ rescue StandardError => e
196
+ # Discovery must never break `clacky agent`.
197
+ Clacky::Logger.debug("[CLI] discover_sibling_server! failed: #{e.class}: #{e.message}")
198
+ end
199
+
151
200
  # Handle the `/config` slash command.
152
201
  #
153
202
  # show_config_modal is a pure UI component — it only mutates @models
@@ -943,6 +992,22 @@ module Clacky
943
992
  # Spawned by Master. Inherit the listen socket from the file descriptor
944
993
  # passed via CLACKY_INHERIT_FD, and report back to master via CLACKY_MASTER_PID.
945
994
  require_relative "server/http_server"
995
+ require_relative "server/epipe_safe_io"
996
+
997
+ # Protect $stdout / $stderr from Errno::EPIPE.
998
+ #
999
+ # The worker inherits fd 1/2 from the Master process. If the Master's
1000
+ # stdout pipe ever breaks (e.g. it was launched by an installer or GUI
1001
+ # that has since exited), the next `puts` would raise Errno::EPIPE and
1002
+ # crash the worker — destroying all in-memory sessions, agent loops,
1003
+ # and SSE connections, and looping forever because the respawned
1004
+ # worker inherits the same broken fd.
1005
+ #
1006
+ # In healthy state these wrappers are transparent — output goes to
1007
+ # the user's terminal as usual. On first broken-pipe failure they
1008
+ # silently fall back to /dev/null and the worker stays alive.
1009
+ $stdout = Clacky::Server::EPIPESafeIO.new($stdout)
1010
+ $stderr = Clacky::Server::EPIPESafeIO.new($stderr)
946
1011
 
947
1012
  fd = ENV["CLACKY_INHERIT_FD"].to_i
948
1013
  master_pid = ENV["CLACKY_MASTER_PID"].to_i
@@ -4,9 +4,10 @@ description: |
4
4
  Configure IM platform channels (Feishu, WeCom, Weixin) for openclacky.
5
5
  Uses browser automation for navigation; guides the user to paste credentials and perform UI steps.
6
6
  Trigger on: "channel setup", "setup feishu", "setup wecom", "setup weixin", "setup wechat", "channel config",
7
- "channel status", "channel enable", "channel disable", "channel reconfigure", "channel doctor".
8
- Subcommands: setup, status, enable <platform>, disable <platform>, reconfigure, doctor.
9
- argument-hint: "setup | status | enable <platform> | disable <platform> | reconfigure | doctor"
7
+ "channel status", "channel enable", "channel disable", "channel reconfigure", "channel doctor",
8
+ "send message to weixin", "send message to feishu", "send message to wecom".
9
+ Subcommands: setup, status, enable <platform>, disable <platform>, reconfigure, doctor, send.
10
+ argument-hint: "setup | status | enable <platform> | disable <platform> | reconfigure | doctor | send <platform> <message>"
10
11
  allowed-tools:
11
12
  - Bash
12
13
  - Read
@@ -33,6 +34,7 @@ Configure IM platform channels for openclacky.
33
34
  | `channel disable feishu/wecom/weixin` | disable |
34
35
  | `channel reconfigure` | reconfigure |
35
36
  | `channel doctor` | doctor |
37
+ | `send <message> to weixin/feishu/wecom` | send |
36
38
 
37
39
  ---
38
40
 
@@ -347,6 +349,58 @@ Check each item, report ✅ / ❌ with remediation:
347
349
 
348
350
  ---
349
351
 
352
+ ## `send`
353
+
354
+ Proactively send a message to a user via an IM channel adapter.
355
+
356
+ ### Parse the request
357
+
358
+ Extract two things from the user's instruction:
359
+ - **platform** — one of `weixin`, `feishu`, `wecom`
360
+ - **message** — the text content to send
361
+
362
+ If the platform cannot be inferred, ask the user to clarify.
363
+
364
+ ### Step 1 — Resolve target user (optional)
365
+
366
+ If the user specified a `user_id`, use it directly.
367
+
368
+ Otherwise, list known users first:
369
+
370
+ ```bash
371
+ curl -s http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/<platform>/users
372
+ ```
373
+
374
+ - If the list is **empty**: tell the user "No known users for `<platform>`. The target user must send at least one message to the bot before proactive messaging is possible." Stop here.
375
+ - If there is **exactly one** user: use it silently.
376
+ - If there are **multiple** users: show the list and ask which one to send to, unless the user already specified one.
377
+
378
+ ### Step 2 — Send the message
379
+
380
+ ```bash
381
+ curl -s -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/<platform>/send \
382
+ -H "Content-Type: application/json" \
383
+ -d '{"message": "<message>", "user_id": "<user_id>"}'
384
+ ```
385
+
386
+ **Response handling:**
387
+
388
+ | HTTP status | Meaning | Action |
389
+ |---|---|---|
390
+ | `200 { ok: true }` | Delivered | Tell user: "✅ Message sent to `<platform>`." |
391
+ | `400` platform not running | Adapter is stopped | Tell user the platform is not running and suggest `channel enable <platform>`. |
392
+ | `400` no context_token | Token missing | Explain: "The bot has no active session token for this user. Ask the user to send any message to the bot first, then retry." |
393
+ | `503` no known users | Nobody has messaged the bot | Same guidance as empty user list above. |
394
+ | Other error | Unexpected | Show the error message from the response body. |
395
+
396
+ ### Constraints & notes
397
+
398
+ - **Weixin (iLink protocol)**: Every outbound message requires a `context_token` that is obtained from the most recent inbound message from that user. The token is cached in memory and reset on server restart. If the server was restarted since the user last wrote, the token is gone and the send will fail — the user must message the bot again.
399
+ - **Feishu / WeCom**: No token required. As long as the adapter is running and the `user_id` / `chat_id` is valid, the message will be delivered.
400
+ - This feature is intended for **proactive notifications** (e.g. task completion, reminders). It is not a replacement for the normal reply flow triggered by inbound messages.
401
+
402
+ ---
403
+
350
404
  ## Security
351
405
 
352
406
  - Always mask secrets in output (last 4 chars only).
@@ -210,7 +210,16 @@ zh template:
210
210
  [1–2 句话,根据用户目标和背景量身定制。]
211
211
  ```
212
212
 
213
- ### A.9. Import external skills (optional)
213
+ ### A.9. Pre-install builtin skills
214
+
215
+ Silently run `ruby "SKILL_DIR/scripts/install_builtin_skills.rb"`,
216
+ then parse the last stdout line as JSON and read `installed` as N.
217
+
218
+ - If N > 0, show one line:
219
+ - zh: `✅ 已为你内置 N 个技能,输入 /skills 随时查看。`
220
+ - en: `✅ Installed N builtin skills. Type /skills anytime to view them.`
221
+
222
+ ### A.10. Import external skills (optional)
214
223
 
215
224
  Run `test -d ~/.openclaw && echo yes || echo no`. If `no`, skip silently.
216
225
  If `yes`:
@@ -221,7 +230,7 @@ If `yes`:
221
230
  - en: `{ "question": "OpenClaw detected. Found N skills. Import them into Clacky?", "options": ["Import", "Skip"] }`
222
231
  4. If confirmed: `ruby "SKILL_DIR/scripts/import_external_skills.rb" --source openclaw --yes`
223
232
 
224
- ### A.10. Celebrate soul setup & offer browser
233
+ ### A.11. Celebrate soul setup & offer browser
225
234
 
226
235
  zh:
227
236
  > ✅ 你的专属 AI 灵魂已设定完成![ai.name] 已经准备好了。
@@ -240,14 +249,14 @@ en: `{ "question": "Want to set up browser automation now? (You can always run /
240
249
 
241
250
  If chosen → invoke `browser-setup` skill with subcommand `setup`.
242
251
 
243
- ### A.11. Offer personal website
252
+ ### A.12. Offer personal website
244
253
 
245
254
  zh: `{ "question": "还有一件有意思的事:要帮你生成一个个人主页吗?我会根据你刚才分享的信息做一个,生成后你会得到一个公开链接。", "options": ["生成主页", "跳过,完成设置"] }`
246
255
  en: `{ "question": "One more thing: want me to generate a personal website from the info you just shared? You'll get a public link you can share.", "options": ["Generate my site", "Skip, I'm done"] }`
247
256
 
248
257
  If chosen → invoke `personal-website` skill.
249
258
 
250
- ### A.12. Confirm and close
259
+ ### A.13. Confirm and close
251
260
 
252
261
  Speak as [ai.name]. This is the AI's first moment of truly being alive — it has a soul,
253
262
  it knows its person, it has hands and eyes, and it just did its first real thing in the world.
@@ -315,7 +324,7 @@ en:
315
324
 
316
325
  Do NOT open a new session — the UI handles navigation after the skill finishes.
317
326
 
318
- ### A.13. First-run notes
327
+ ### A.14. First-run notes
319
328
 
320
329
  - Keep both files under 300 words each.
321
330
  - Do not ask follow-up questions beyond the cards above.
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Install builtin skills into ~/.clacky/skills/.
5
+ #
6
+ # Fetches the server-curated builtin list from GET /api/v1/skills/builtin on
7
+ # the openclacky platform (public, no auth), then downloads and installs each
8
+ # skill's zip package in parallel (5 workers, 30s total timeout).
9
+ #
10
+ # The "builtin" whitelist is enforced server-side — this script takes no
11
+ # filter flags. Admin toggles the `builtin` flag per skill on the platform.
12
+ #
13
+ # Called by onboard skill: `ruby install_builtin_skills.rb`
14
+ #
15
+ # Output:
16
+ # - Diagnostics → STDERR
17
+ # - Last line of STDOUT → JSON: {"installed":N,"attempted":N,"skipped_existing":N}
18
+ # - Exit code: always 0
19
+
20
+ require 'uri'
21
+ require 'net/http'
22
+ require 'json'
23
+ require 'timeout'
24
+
25
+ # Reuse the downloader/extractor/installer from the skill-add skill.
26
+ # Physical relocation to lib/clacky/ is deferred until a third caller appears.
27
+ require_relative '../../skill-add/scripts/install_from_zip'
28
+
29
+ class BuiltinSkillsInstaller
30
+ PRIMARY_HOST = ENV.fetch('CLACKY_LICENSE_SERVER', 'https://www.openclacky.com')
31
+ FALLBACK_HOST = 'https://openclacky.up.railway.app'
32
+ API_HOSTS = ENV['CLACKY_LICENSE_SERVER'] ? [PRIMARY_HOST] : [PRIMARY_HOST, FALLBACK_HOST]
33
+ API_PATH = '/api/v1/skills/builtin'
34
+ API_OPEN_TIMEOUT = 5
35
+ API_READ_TIMEOUT = 10
36
+ CONCURRENCY = 5
37
+
38
+ def initialize
39
+ @target_dir = File.join(Dir.home, '.clacky', 'skills')
40
+ @per_skill_timeout = 10
41
+ @total_timeout = 30
42
+
43
+ @installed = 0
44
+ @skipped_existing = 0
45
+ @attempted = 0
46
+ @errors = []
47
+ @mutex = Mutex.new
48
+ end
49
+
50
+ def run
51
+ skills = fetch_skill_list
52
+ if skills.nil? || skills.empty?
53
+ emit_summary
54
+ return
55
+ end
56
+
57
+ install_concurrently(skills)
58
+ ensure
59
+ emit_summary
60
+ end
61
+
62
+ # --- Internals -------------------------------------------------------------
63
+
64
+ # Returns an array of skill hashes, or nil on total failure.
65
+ private def fetch_skill_list
66
+ API_HOSTS.each do |host|
67
+ begin
68
+ uri = URI.parse(host + API_PATH)
69
+ Net::HTTP.start(uri.host, uri.port,
70
+ use_ssl: uri.scheme == 'https',
71
+ open_timeout: API_OPEN_TIMEOUT,
72
+ read_timeout: API_READ_TIMEOUT) do |http|
73
+ response = http.request(Net::HTTP::Get.new(uri.request_uri))
74
+ if response.code.to_i == 200
75
+ payload = JSON.parse(response.body)
76
+ return Array(payload['skills'])
77
+ else
78
+ @errors << "API #{host}: HTTP #{response.code}"
79
+ end
80
+ end
81
+ rescue StandardError => e
82
+ @errors << "API #{host}: #{e.class}: #{e.message}"
83
+ end
84
+ end
85
+ nil
86
+ end
87
+
88
+ # Install skills in parallel, bounded by CONCURRENCY and @total_timeout.
89
+ # Workers pull from a shared queue and self-check the deadline, so the
90
+ # global timeout is enforced without killing threads mid-download (which
91
+ # would leak temp dirs). Whatever finishes before the deadline stays
92
+ # installed; the rest is recovered on the next onboard run via skip_if_exists.
93
+ private def install_concurrently(skills)
94
+ queue = Queue.new
95
+ skills.each { |s| queue << s }
96
+
97
+ deadline = Time.now + @total_timeout
98
+ worker_pool = [CONCURRENCY, skills.size].min
99
+
100
+ workers = Array.new(worker_pool) do
101
+ Thread.new do
102
+ loop do
103
+ break if Time.now >= deadline
104
+ skill = queue.pop(true) rescue nil # non-blocking pop
105
+ break if skill.nil?
106
+ install_one(skill)
107
+ end
108
+ end
109
+ end
110
+
111
+ workers.each(&:join)
112
+
113
+ # If the deadline cut us off with items still in the queue, record it.
114
+ remaining = queue.size
115
+ if remaining.positive?
116
+ @mutex.synchronize do
117
+ @errors << "overall timeout after #{@total_timeout}s " \
118
+ "(installed=#{@installed}, attempted=#{@attempted}, remaining=#{remaining})"
119
+ end
120
+ end
121
+ end
122
+
123
+ # Install one skill entry (hash from the API payload).
124
+ # Bounded by @per_skill_timeout; any failure is swallowed into @errors.
125
+ # Thread-safe: all shared state writes go through @mutex.
126
+ private def install_one(skill)
127
+ name = skill['name'].to_s
128
+ download_url = skill['download_url'].to_s
129
+
130
+ @mutex.synchronize { @attempted += 1 }
131
+
132
+ if name.empty? || download_url.empty?
133
+ @mutex.synchronize do
134
+ @errors << "skill payload missing name or download_url: #{skill.inspect}"
135
+ end
136
+ return
137
+ end
138
+
139
+ Timeout.timeout(@per_skill_timeout) do
140
+ installer = ZipSkillInstaller.new(
141
+ download_url,
142
+ skill_name: name,
143
+ target_dir: @target_dir,
144
+ skip_if_exists: true
145
+ )
146
+ result = installer.perform
147
+ @mutex.synchronize do
148
+ @installed += result[:installed].size
149
+ @skipped_existing += result[:skipped].size
150
+ @errors.concat(result[:errors]) if result[:errors].any?
151
+ end
152
+ end
153
+ rescue Timeout::Error
154
+ @mutex.synchronize { @errors << "#{name}: install timeout after #{@per_skill_timeout}s" }
155
+ rescue StandardError => e
156
+ @mutex.synchronize { @errors << "#{name}: #{e.class}: #{e.message}" }
157
+ end
158
+
159
+ # Diagnostics to stderr; single-line JSON summary to stdout.
160
+ # The caller (onboard) should parse the LAST stdout line.
161
+ private def emit_summary
162
+ unless @errors.empty?
163
+ warn '[install_builtin_skills] non-fatal errors:'
164
+ @errors.each { |e| warn " - #{e}" }
165
+ end
166
+ puts JSON.generate(
167
+ installed: @installed,
168
+ attempted: @attempted,
169
+ skipped_existing: @skipped_existing
170
+ )
171
+ end
172
+ end
173
+
174
+ # ── Entry point ───────────────────────────────────────────────────────────────
175
+ BuiltinSkillsInstaller.new.run if __FILE__ == $0