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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/lib/clacky/agent.rb +3 -0
- data/lib/clacky/agent_config.rb +91 -7
- data/lib/clacky/billing/billing_store.rb +107 -3
- data/lib/clacky/cli.rb +105 -0
- data/lib/clacky/client.rb +38 -5
- data/lib/clacky/default_skills/channel-manager/SKILL.md +33 -110
- data/lib/clacky/default_skills/deploy/SKILL.md +2 -1
- data/lib/clacky/default_skills/extend-openclacky/SKILL.md +39 -0
- data/lib/clacky/default_skills/mcp-manager/SKILL.md +0 -7
- data/lib/clacky/default_skills/media-gen/SKILL.md +128 -0
- data/lib/clacky/media/base.rb +68 -0
- data/lib/clacky/media/gemini.rb +36 -0
- data/lib/clacky/media/generator.rb +78 -0
- data/lib/clacky/media/openai_compat.rb +168 -0
- data/lib/clacky/patch_loader.rb +282 -0
- data/lib/clacky/providers.rb +82 -0
- data/lib/clacky/server/channel/adapters/base.rb +4 -0
- data/lib/clacky/server/channel/channel_manager.rb +1 -1
- data/lib/clacky/server/channel/user_adapter_loader.rb +177 -0
- data/lib/clacky/server/channel.rb +5 -0
- data/lib/clacky/server/http_server.rb +236 -25
- data/lib/clacky/server/scheduler.rb +1 -4
- data/lib/clacky/shell_hook_loader.rb +181 -0
- data/lib/clacky/telemetry.rb +11 -5
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +326 -24
- data/lib/clacky/web/billing.js +117 -22
- data/lib/clacky/web/i18n.js +84 -6
- data/lib/clacky/web/index.html +14 -2
- data/lib/clacky/web/model-tester.js +58 -0
- data/lib/clacky/web/onboard.js +17 -30
- data/lib/clacky/web/settings.js +322 -97
- data/lib/clacky.rb +9 -0
- data/scripts/build/lib/network.sh +61 -30
- data/scripts/install.sh +61 -30
- data/scripts/install_browser.sh +61 -30
- data/scripts/install_full.sh +61 -30
- data/scripts/install_rails_deps.sh +61 -30
- data/scripts/install_system_deps.sh +61 -30
- metadata +12 -3
- 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 ["
|
|
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
|
-
#
|
|
1174
|
-
#
|
|
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
|
|
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 (
|
|
3270
|
-
|
|
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
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
json_response(res, 200, { ok: false, 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
|
|
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,
|
|
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
|
data/lib/clacky/telemetry.rb
CHANGED
|
@@ -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:
|
|
36
|
-
version:
|
|
37
|
-
os:
|
|
38
|
-
ruby_version:
|
|
39
|
-
brand:
|
|
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)
|
data/lib/clacky/version.rb
CHANGED