openclacky 1.2.7 → 1.2.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/lib/clacky/agent.rb +3 -0
  4. data/lib/clacky/agent_config.rb +91 -7
  5. data/lib/clacky/billing/billing_store.rb +107 -3
  6. data/lib/clacky/cli.rb +105 -0
  7. data/lib/clacky/client.rb +38 -5
  8. data/lib/clacky/default_skills/channel-manager/SKILL.md +33 -110
  9. data/lib/clacky/default_skills/deploy/SKILL.md +2 -1
  10. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +39 -0
  11. data/lib/clacky/default_skills/mcp-manager/SKILL.md +0 -7
  12. data/lib/clacky/default_skills/media-gen/SKILL.md +128 -0
  13. data/lib/clacky/media/base.rb +68 -0
  14. data/lib/clacky/media/gemini.rb +36 -0
  15. data/lib/clacky/media/generator.rb +78 -0
  16. data/lib/clacky/media/openai_compat.rb +168 -0
  17. data/lib/clacky/patch_loader.rb +282 -0
  18. data/lib/clacky/providers.rb +82 -0
  19. data/lib/clacky/server/channel/adapters/base.rb +4 -0
  20. data/lib/clacky/server/channel/channel_manager.rb +1 -1
  21. data/lib/clacky/server/channel/user_adapter_loader.rb +177 -0
  22. data/lib/clacky/server/channel.rb +5 -0
  23. data/lib/clacky/server/http_server.rb +236 -25
  24. data/lib/clacky/server/scheduler.rb +1 -4
  25. data/lib/clacky/shell_hook_loader.rb +181 -0
  26. data/lib/clacky/telemetry.rb +11 -5
  27. data/lib/clacky/version.rb +1 -1
  28. data/lib/clacky/web/app.css +326 -24
  29. data/lib/clacky/web/billing.js +117 -22
  30. data/lib/clacky/web/i18n.js +84 -6
  31. data/lib/clacky/web/index.html +14 -2
  32. data/lib/clacky/web/model-tester.js +58 -0
  33. data/lib/clacky/web/onboard.js +17 -30
  34. data/lib/clacky/web/settings.js +322 -97
  35. data/lib/clacky.rb +9 -0
  36. data/scripts/build/lib/network.sh +61 -30
  37. data/scripts/install.sh +61 -30
  38. data/scripts/install_browser.sh +61 -30
  39. data/scripts/install_full.sh +61 -30
  40. data/scripts/install_rails_deps.sh +61 -30
  41. data/scripts/install_system_deps.sh +61 -30
  42. metadata +12 -3
  43. data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +0 -574
@@ -370,6 +370,11 @@ module Clacky
370
370
  30
371
371
  elsif path.end_with?("/benchmark")
372
372
  20
373
+ elsif path == "/api/media/image"
374
+ # Image generation routes through OpenRouter (chat completions
375
+ # with modalities:["image"]); end-to-end latency is commonly
376
+ # 20-60s and can exceed 2 minutes for or-gpt-image-2 under load.
377
+ 300
373
378
  else
374
379
  10
375
380
  end
@@ -399,6 +404,7 @@ module Clacky
399
404
  when ["PATCH", "/api/config/settings"] then api_update_settings(req, res)
400
405
  when ["POST", "/api/config/models"] then api_add_model(req, res)
401
406
  when ["POST", "/api/config/test"] then api_test_config(req, res)
407
+ when ["GET", "/api/config/media"] then api_get_media_config(res)
402
408
  when ["GET", "/api/providers"] then api_list_providers(res)
403
409
  when ["GET", "/api/onboard/status"] then api_onboard_status(res)
404
410
  when ["GET", "/api/browser/status"] then api_browser_status(res)
@@ -430,14 +436,16 @@ module Clacky
430
436
  when ["POST", "/api/upload"] then api_upload_file(req, res)
431
437
  when ["POST", "/api/file-action"] then api_file_action(req, res)
432
438
  when ["GET", "/api/local-image"] then api_serve_local_image(req, res)
439
+ when ["POST", "/api/media/image"] then api_media_image(req, res)
440
+ when ["GET", "/api/media/types"] then api_media_types(res)
433
441
  when ["GET", "/api/version"] then api_get_version(res)
434
442
  when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
435
443
  when ["POST", "/api/restart"] then api_restart(req, res)
436
444
  when ["GET", "/api/billing/summary"] then api_billing_summary(req, res)
437
445
  when ["GET", "/api/billing/daily"] then api_billing_daily(req, res)
438
446
  when ["GET", "/api/billing/records"] then api_billing_records(req, res)
439
- when ["DELETE", "/api/billing/clear"] then api_billing_clear(req, res)
440
- when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
447
+ when ["GET", "/api/billing/sessions"] then api_billing_sessions(req, res)
448
+ when ["DELETE", "/api/billing/clear"] then api_billing_clear(req, res) when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
441
449
  when ["PATCH", "/api/sessions/:id/working_dir"] then api_change_session_working_dir(req, res)
442
450
  else
443
451
  if method == "POST" && path.match?(%r{^/api/channels/[^/]+/send$})
@@ -523,6 +531,9 @@ module Clacky
523
531
  elsif method == "DELETE" && path.match?(%r{^/api/config/models/[^/]+$})
524
532
  id = path.sub("/api/config/models/", "")
525
533
  api_delete_model(id, res)
534
+ elsif method == "PATCH" && path.match?(%r{^/api/config/media/(image|video|audio)$})
535
+ kind = path.sub("/api/config/media/", "")
536
+ api_update_media_config(kind, req, res)
526
537
  elsif method == "POST" && path.match?(%r{^/api/cron-tasks/[^/]+/run$})
527
538
  name = URI.decode_www_form_component(path.sub("/api/cron-tasks/", "").sub("/run", ""))
528
539
  api_run_cron_task(name, res)
@@ -697,6 +708,158 @@ module Clacky
697
708
  json_response(res, 500, { ok: false, error: e.message })
698
709
  end
699
710
 
711
+ # POST /api/media/image
712
+ # Body: { "prompt": "...", "aspect_ratio": "landscape|square|portrait",
713
+ # "output_dir": "<absolute path, optional>" }
714
+ # Routes to the model configured with type=image in agent_config.
715
+ def api_media_image(req, res)
716
+ body = parse_json_body(req)
717
+ return json_response(res, 400, { error: "Invalid JSON" }) unless body
718
+
719
+ prompt = body["prompt"].to_s
720
+ if prompt.strip.empty?
721
+ return json_response(res, 422, { error: "prompt is required" })
722
+ end
723
+
724
+ aspect_ratio = body["aspect_ratio"].to_s
725
+ aspect_ratio = "landscape" if aspect_ratio.empty?
726
+ output_dir = body["output_dir"].to_s
727
+ output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
728
+
729
+ result = Clacky::Media::Generator.new(@agent_config).generate_image(
730
+ prompt: prompt,
731
+ aspect_ratio: aspect_ratio,
732
+ output_dir: output_dir
733
+ )
734
+ if result["success"]
735
+ log_media_usage(result, prompt: prompt)
736
+ end
737
+ status = result["success"] ? 200 : 422
738
+ json_response(res, status, result)
739
+ rescue StandardError => e
740
+ json_response(res, 500, { error: e.message })
741
+ end
742
+
743
+ private def log_media_usage(result, prompt:)
744
+ usage = result["usage"]
745
+ cost = result["cost_usd"]
746
+ return if usage.nil? && cost.nil?
747
+
748
+ parts = []
749
+ parts << "model=#{result["model"]}"
750
+ parts << "provider=#{result["provider"]}"
751
+ if usage.is_a?(Hash)
752
+ parts << "prompt_tokens=#{usage["prompt_tokens"]}"
753
+ parts << "completion_tokens=#{usage["completion_tokens"]}"
754
+ parts << "cache_read=#{usage["cache_read_tokens"]}" if usage["cache_read_tokens"].to_i > 0
755
+ parts << "cache_write=#{usage["cache_write_tokens"]}" if usage["cache_write_tokens"].to_i > 0
756
+ end
757
+ parts << format("cost_usd=%.6f", cost.to_f) if cost
758
+ parts << "prompt=#{prompt[0, 60].inspect}"
759
+ Clacky::Logger.info("[Media] image generated #{parts.join(" ")}")
760
+ end
761
+
762
+ # GET /api/media/types
763
+ # Returns which media types are configured in agent_config.models.
764
+ # Used by the media-gen skill to decide whether to surface generation
765
+ # capabilities to the user.
766
+ def api_media_types(res)
767
+ out = {}
768
+ Clacky::Providers::MEDIA_KINDS.each do |t|
769
+ state = @agent_config.media_state(t)
770
+ out[t] =
771
+ if state["configured"]
772
+ {
773
+ configured: true,
774
+ model: state["model"],
775
+ base_url: state["base_url"],
776
+ source: state["source"]
777
+ }
778
+ else
779
+ { configured: false, source: "off" }
780
+ end
781
+ end
782
+ json_response(res, 200, out)
783
+ end
784
+
785
+ # GET /api/config/media
786
+ # Used by the Settings UI to render the tri-state media controls.
787
+ # Per-kind payload mirrors AgentConfig#media_state.
788
+ def api_get_media_config(res)
789
+ out = {}
790
+ Clacky::Providers::MEDIA_KINDS.each do |t|
791
+ state = @agent_config.media_state(t)
792
+ entry = @agent_config.find_model_by_type(t)
793
+ out[t] = {
794
+ source: state["source"],
795
+ model: state["model"],
796
+ base_url: state["base_url"],
797
+ api_key_masked: entry ? mask_api_key(entry["api_key"]) : nil,
798
+ provider: state["provider"],
799
+ available: state["available"],
800
+ configured: state["configured"]
801
+ }
802
+ end
803
+
804
+ # Surface what the current default model can offer, even when the
805
+ # user is currently in "off" — the UI uses this to render the
806
+ # auto-mode preview ("Auto would use X").
807
+ default = @agent_config.find_model_by_type("default")
808
+ provider_id = default && Clacky::Providers.resolve_provider(
809
+ base_url: default["base_url"],
810
+ api_key: default["api_key"]
811
+ )
812
+ defaults = {}
813
+ Clacky::Providers::MEDIA_KINDS.each do |t|
814
+ defaults[t] = {
815
+ provider: provider_id,
816
+ model: provider_id ? Clacky::Providers.default_media_model(provider_id, t) : nil,
817
+ available: provider_id ? Clacky::Providers.media_models(provider_id, t) : []
818
+ }
819
+ end
820
+
821
+ json_response(res, 200, { media: out, default_provider: defaults })
822
+ end
823
+
824
+ # PATCH /api/config/media/:kind
825
+ # Body: { source: "off"|"auto"|"custom", model?, base_url?, api_key?,
826
+ # anthropic_format? }
827
+ # off / auto — remove any custom entry; "auto" lets the virtual
828
+ # derivation in AgentConfig#find_model_by_type take over.
829
+ # custom — replace any existing custom entry with the supplied fields.
830
+ def api_update_media_config(kind, req, res)
831
+ body = parse_json_body(req) || {}
832
+ source = body["source"].to_s
833
+ unless %w[off auto custom].include?(source)
834
+ return json_response(res, 422, { error: "invalid source" })
835
+ end
836
+
837
+ @agent_config.models.reject! { |m| m["type"] == kind }
838
+
839
+ if source == "custom"
840
+ model = body["model"].to_s.strip
841
+ base_url = body["base_url"].to_s.strip
842
+ api_key = body["api_key"].to_s
843
+ if model.empty? || base_url.empty? || api_key.empty? || api_key.include?("****")
844
+ return json_response(res, 422, { error: "model, base_url, api_key are required" })
845
+ end
846
+
847
+ @agent_config.models << {
848
+ "id" => SecureRandom.uuid,
849
+ "model" => model,
850
+ "base_url" => base_url,
851
+ "api_key" => api_key,
852
+ "anthropic_format" => body["anthropic_format"] || false,
853
+ "type" => kind
854
+ }
855
+ end
856
+
857
+ @agent_config.save
858
+ json_response(res, 200, { ok: true, state: @agent_config.media_state(kind) })
859
+ rescue => e
860
+ json_response(res, 422, { error: e.message })
861
+ end
862
+
700
863
  # POST /api/onboard/complete
701
864
  # Called after key setup is done (soul_setup is optional/skipped).
702
865
  # Creates the default session if none exists yet, returns it.
@@ -1170,8 +1333,27 @@ module Clacky
1170
1333
  })
1171
1334
  end
1172
1335
 
1173
- # DELETE /api/billing/clear
1174
- # Clears billing records
1336
+ # GET /api/billing/sessions
1337
+ # Returns session-level billing summary
1338
+ # Query params: period (day|week|month|year|all, default: month), model, limit
1339
+ def api_billing_sessions(req, res)
1340
+ require_relative "../billing/billing_store"
1341
+
1342
+ query = URI.decode_www_form(req.query_string.to_s).to_h
1343
+ period = (query["period"] || "month").to_sym
1344
+ model = query["model"]
1345
+ limit = [(query["limit"] || "50").to_i, 200].min
1346
+
1347
+ store = Clacky::Billing::BillingStore.new
1348
+ sessions = store.session_summary(period: period, model: model, limit: limit)
1349
+
1350
+ json_response(res, 200, {
1351
+ sessions: sessions,
1352
+ count: sessions.size
1353
+ })
1354
+ end
1355
+
1356
+ # DELETE /api/billing/clear # Clears billing records
1175
1357
  # Query params: scope (today|all, default: today)
1176
1358
  def api_billing_clear(req, res)
1177
1359
  require_relative "../billing/billing_store"
@@ -1595,7 +1777,7 @@ module Clacky
1595
1777
  # Returns current config and running status for all supported platforms.
1596
1778
  # POST /api/tool/browser
1597
1779
  # Executes a browser tool action via the shared BrowserManager daemon.
1598
- # Used by skill scripts (e.g. feishu_setup.rb) to reuse the server's
1780
+ # Used by skill scripts to reuse the server's
1599
1781
  # existing Chrome connection without spawning a second MCP daemon.
1600
1782
  #
1601
1783
  # Request body: JSON with same params as the browser tool
@@ -3266,8 +3448,13 @@ module Clacky
3266
3448
  type: m["type"]
3267
3449
  }
3268
3450
  end
3269
- # Filter out auto-injected models (like lite) from UI display
3270
- models.reject! { |m| @agent_config.models[m[:index]]["auto_injected"] }
3451
+ # Filter out auto-injected models (lite, derived media) AND media
3452
+ # entries (image/video/audio) those are managed via the dedicated
3453
+ # media-config UI, not the chat-model card list.
3454
+ models.reject! do |m|
3455
+ raw = @agent_config.models[m[:index]]
3456
+ raw["auto_injected"] || Clacky::Providers::MEDIA_KINDS.include?(raw["type"].to_s)
3457
+ end
3271
3458
  json_response(res, 200, {
3272
3459
  models: models,
3273
3460
  current_index: @agent_config.current_model_index,
@@ -3487,29 +3674,51 @@ module Clacky
3487
3674
  return json_response(res, 400, { error: "Invalid JSON" }) unless body
3488
3675
 
3489
3676
  api_key = body["api_key"].to_s
3490
- # If masked, use the stored key from the matching model (by index or current)
3491
3677
  if api_key.include?("****")
3492
3678
  idx = body["index"]&.to_i || @agent_config.current_model_index
3493
3679
  api_key = @agent_config.models.dig(idx, "api_key").to_s
3494
3680
  end
3495
3681
 
3496
- begin
3497
- model = body["model"].to_s
3498
- test_client = Clacky::Client.new(
3499
- api_key,
3500
- base_url: body["base_url"].to_s,
3501
- model: model,
3502
- anthropic_format: body["anthropic_format"] || false
3503
- )
3504
- result = test_client.test_connection(model: model)
3505
- if result[:success]
3506
- json_response(res, 200, { ok: true, message: "Connected successfully" })
3507
- else
3508
- json_response(res, 200, { ok: false, message: result[:error].to_s })
3509
- end
3510
- rescue => e
3511
- json_response(res, 200, { ok: false, message: e.message })
3682
+ model = body["model"].to_s
3683
+ base_url = body["base_url"].to_s
3684
+ anthropic_format = body["anthropic_format"] || false
3685
+
3686
+ result, used_base_url = try_test_with_base_url(api_key, base_url, model, anthropic_format)
3687
+
3688
+ if result[:success] && used_base_url != base_url
3689
+ json_response(res, 200, {
3690
+ ok: true,
3691
+ message: "Connected (auto-corrected base_url to add /v1)",
3692
+ effective_base_url: used_base_url
3693
+ })
3694
+ elsif result[:success]
3695
+ json_response(res, 200, { ok: true, message: "Connected successfully" })
3696
+ else
3697
+ json_response(res, 200, { ok: false, message: result[:error].to_s })
3512
3698
  end
3699
+ rescue => e
3700
+ json_response(res, 200, { ok: false, message: e.message })
3701
+ end
3702
+
3703
+ private def try_test_with_base_url(api_key, base_url, model, anthropic_format)
3704
+ result = run_test_connection(api_key, base_url, model, anthropic_format)
3705
+ return [result, base_url] if result[:success]
3706
+ return [result, base_url] unless result[:status] == 404
3707
+ return [result, base_url] if base_url.match?(%r{/v\d+/?\z})
3708
+
3709
+ candidate = "#{base_url.chomp("/")}/v1"
3710
+ retried = run_test_connection(api_key, candidate, model, anthropic_format)
3711
+ retried[:success] ? [retried, candidate] : [result, base_url]
3712
+ end
3713
+
3714
+ private def run_test_connection(api_key, base_url, model, anthropic_format)
3715
+ client = Clacky::Client.new(
3716
+ api_key,
3717
+ base_url: base_url,
3718
+ model: model,
3719
+ anthropic_format: anthropic_format
3720
+ )
3721
+ client.test_connection(model: model)
3513
3722
  end
3514
3723
 
3515
3724
  # GET /api/providers — return built-in provider presets for quick setup
@@ -4386,7 +4595,9 @@ module Clacky
4386
4595
  # @param working_dir [String] working directory for the agent
4387
4596
  # @param permission_mode [Symbol] :confirm_all (default, human present) or
4388
4597
  # :auto_approve (unattended — suppresses request_user_feedback waits)
4389
- def build_session(name:, working_dir:, permission_mode: :confirm_all, profile: "general", source: :manual, model_id: nil)
4598
+ def build_session(name:, working_dir: nil, permission_mode: :confirm_all, profile: "general", source: :manual, model_id: nil)
4599
+ working_dir ||= default_working_dir
4600
+ FileUtils.mkdir_p(working_dir) unless Dir.exist?(working_dir)
4390
4601
  session_id = Clacky::SessionManager.generate_id
4391
4602
  @registry.create(session_id: session_id)
4392
4603
 
@@ -223,11 +223,8 @@ module Clacky
223
223
  prompt = read_task(task_name)
224
224
  name = "⏰ #{schedule["name"]} #{Time.now.strftime("%H:%M")}"
225
225
 
226
- working_dir = File.expand_path("~/clacky_workspace")
227
- FileUtils.mkdir_p(working_dir)
228
-
229
226
  # Scheduled tasks run unattended — use auto_approve so request_user_feedback doesn't block.
230
- session_id = @session_builder.call(name: name, working_dir: working_dir, permission_mode: :auto_approve, source: :cron)
227
+ session_id = @session_builder.call(name: name, permission_mode: :auto_approve, source: :cron)
231
228
 
232
229
  Clacky::Logger.info("scheduler_task_fired", task: task_name, session: session_id)
233
230
 
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "timeout"
6
+ require "yaml"
7
+ require "fileutils"
8
+
9
+ module Clacky
10
+ # Loads declarative, shell-based hooks from ~/.clacky/hooks.yml and registers
11
+ # them on a HookManager. Each hook runs an external command rather than Ruby in
12
+ # the agent process, which keeps user-authored hooks sandboxed and safe.
13
+ #
14
+ # hooks.yml format:
15
+ # hooks:
16
+ # before_tool_use:
17
+ # - name: guard # optional label for logs
18
+ # command: "~/.clacky/hook-scripts/guard.sh"
19
+ # timeout: 10 # optional, seconds (default 10)
20
+ # on_complete:
21
+ # - command: "notify-send done"
22
+ #
23
+ # Runtime contract (per invocation):
24
+ # - The event payload is passed to the command as JSON on STDIN.
25
+ # - exit 0 → allow (default).
26
+ # - exit 2 → deny; STDOUT becomes the denial reason. Only meaningful for
27
+ # before_tool_use, which the agent checks for {action: :deny}.
28
+ # - any other exit / timeout / crash → logged, treated as allow (a broken
29
+ # hook must never wedge the agent).
30
+ class ShellHookLoader
31
+ DEFAULT_PATH = File.expand_path("~/.clacky/hooks.yml")
32
+ DEFAULT_TIMEOUT = 10
33
+ DENY_EXIT_CODE = 2
34
+
35
+ Result = Struct.new(:registered, :skipped, keyword_init: true)
36
+
37
+ def self.load_into(hook_manager, path: DEFAULT_PATH)
38
+ new(path: path).load_into(hook_manager)
39
+ end
40
+
41
+ # Create a starter hooks.yml plus an example guard script. Idempotent-ish:
42
+ # raises if hooks.yml already exists so we never clobber user config.
43
+ # @return [String] path to the created hooks.yml
44
+ def self.scaffold(path: DEFAULT_PATH)
45
+ raise ArgumentError, "hooks file already exists: #{path}" if File.exist?(path)
46
+
47
+ dir = File.dirname(path)
48
+ scripts_dir = File.join(dir, "hook-scripts")
49
+ FileUtils.mkdir_p(scripts_dir)
50
+
51
+ guard = File.join(scripts_dir, "deny-example.sh")
52
+ File.write(guard, <<~SH)
53
+ #!/usr/bin/env bash
54
+ # Example before_tool_use hook.
55
+ # Reads the event JSON on STDIN; exit 2 to DENY, exit 0 to ALLOW.
56
+ # STDOUT on exit 2 becomes the denial reason shown to the agent.
57
+ payload="$(cat)"
58
+ # Example: deny any terminal command containing "rm -rf /"
59
+ if echo "$payload" | grep -q 'rm -rf /'; then
60
+ echo "blocked dangerous command"
61
+ exit 2
62
+ fi
63
+ exit 0
64
+ SH
65
+ FileUtils.chmod("+x", guard)
66
+
67
+ File.write(path, <<~YAML)
68
+ # Declarative shell hooks. Each command receives the event payload as JSON
69
+ # on STDIN. For before_tool_use: exit 2 = deny (STDOUT = reason), exit 0 = allow.
70
+ # Events: #{HookManager::HOOK_EVENTS.join(", ")}
71
+ hooks:
72
+ before_tool_use:
73
+ - name: deny-example
74
+ command: "#{guard}"
75
+ timeout: 10
76
+ # on_complete:
77
+ # - command: "echo task finished"
78
+ YAML
79
+
80
+ path
81
+ end
82
+
83
+ def initialize(path: DEFAULT_PATH)
84
+ @path = path
85
+ end
86
+
87
+ # @return [Result] counts of registered hooks and skipped (with reasons)
88
+ def load_into(hook_manager)
89
+ result = Result.new(registered: [], skipped: [])
90
+ return result unless File.exist?(@path)
91
+
92
+ doc = YAMLCompat.load_file(@path) || {}
93
+ events = doc["hooks"] || {}
94
+
95
+ events.each do |event_name, specs|
96
+ event = event_name.to_sym
97
+ Array(specs).each do |spec|
98
+ register_one(hook_manager, event, spec, result)
99
+ end
100
+ end
101
+
102
+ result
103
+ rescue StandardError => e
104
+ Clacky::Logger.error("[ShellHookLoader] Failed to load #{@path}: #{e.message}")
105
+ result
106
+ end
107
+
108
+ private def register_one(hook_manager, event, spec, result)
109
+ command = spec["command"].to_s.strip
110
+ name = spec["name"] || command
111
+ timeout = (spec["timeout"] || DEFAULT_TIMEOUT).to_i
112
+
113
+ if command.empty?
114
+ result.skipped << [name, "missing command"]
115
+ return
116
+ end
117
+
118
+ unless HookManager::HOOK_EVENTS.include?(event)
119
+ result.skipped << [name, "unknown event: #{event}"]
120
+ return
121
+ end
122
+
123
+ hook_manager.add(event) do |*args|
124
+ run_command(event, command, timeout, args)
125
+ end
126
+ result.registered << [event, name]
127
+ end
128
+
129
+ private def run_command(event, command, timeout, args)
130
+ payload = JSON.generate(build_payload(event, args))
131
+
132
+ out = +""
133
+ status = nil
134
+ Open3.popen3(command) do |stdin, stdout, _stderr, wait_thr|
135
+ stdin.write(payload)
136
+ stdin.close
137
+ if wait_thr.join(timeout)
138
+ out = stdout.read
139
+ status = wait_thr.value
140
+ else
141
+ Process.kill("TERM", wait_thr.pid) rescue nil
142
+ raise Timeout::Error
143
+ end
144
+ end
145
+
146
+ if status&.exitstatus == DENY_EXIT_CODE
147
+ { action: :deny, reason: out.strip.empty? ? "Denied by hook" : out.strip }
148
+ else
149
+ { action: :allow }
150
+ end
151
+ rescue Timeout::Error
152
+ Clacky::Logger.warn("[ShellHookLoader] Hook '#{command}' timed out after #{timeout}s — allowing")
153
+ { action: :allow }
154
+ rescue StandardError => e
155
+ Clacky::Logger.warn("[ShellHookLoader] Hook '#{command}' failed: #{e.message} — allowing")
156
+ { action: :allow }
157
+ end
158
+
159
+ # Normalize the positional trigger args of each event into a JSON-serializable hash.
160
+ private def build_payload(event, args)
161
+ base = { event: event.to_s }
162
+
163
+ case event
164
+ when :before_tool_use, :after_tool_use, :on_tool_error
165
+ base[:tool] = args[0]
166
+ base[:result] = args[1] if args.length > 1 && event == :after_tool_use
167
+ base[:error] = args[1].to_s if event == :on_tool_error && args[1]
168
+ when :on_start
169
+ base[:user_input] = args[0].to_s
170
+ when :on_iteration
171
+ base[:iteration] = args[0]
172
+ when :on_complete
173
+ base[:result] = args[0]
174
+ when :session_rollback
175
+ base[:info] = args[0]
176
+ end
177
+
178
+ base
179
+ end
180
+ end
181
+ end
@@ -23,6 +23,11 @@ module Clacky
23
23
  # POST /api/v1/telemetry/startup
24
24
  # POST /api/v1/telemetry/task
25
25
  module Telemetry
26
+ LAUNCH_SOURCES = {
27
+ "installer" => "installer",
28
+ nil => "cli"
29
+ }.freeze
30
+
26
31
  class << self
27
32
  # Called on every CLI startup (agent and server mode).
28
33
  # No local dedup — the server deduplicates by device_hash for unique
@@ -32,11 +37,12 @@ module Clacky
32
37
 
33
38
  brand = Clacky::BrandConfig.load
34
39
  payload = {
35
- device_id: resolve_device_id(brand),
36
- version: Clacky::VERSION,
37
- os: RbConfig::CONFIG["host_os"],
38
- ruby_version: RUBY_VERSION,
39
- brand: brand.branded? ? brand.package_name : nil
40
+ device_id: resolve_device_id(brand),
41
+ version: Clacky::VERSION,
42
+ os: RbConfig::CONFIG["host_os"],
43
+ ruby_version: RUBY_VERSION,
44
+ brand: brand.branded? ? brand.package_name : nil,
45
+ launch_source: LAUNCH_SOURCES.fetch(ENV["CLACKY_LAUNCHED_BY"], "cli")
40
46
  }.compact
41
47
 
42
48
  fire_and_forget("/api/v1/telemetry/startup", payload)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.2.7"
4
+ VERSION = "1.2.9"
5
5
  end