openclacky 1.3.7 → 1.3.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 +42 -0
- data/lib/clacky/agent/hook_manager.rb +10 -1
- data/lib/clacky/agent/system_prompt_builder.rb +1 -1
- data/lib/clacky/agent.rb +9 -2
- data/lib/clacky/agent_profile.rb +7 -4
- data/lib/clacky/billing/billing_store.rb +3 -2
- data/lib/clacky/brand_config.rb +2 -1
- data/lib/clacky/default_extensions/coding/agents/coding/avatar.png +0 -0
- data/lib/clacky/default_extensions/coding/ext.yml +4 -4
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/avatar.png +0 -0
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +0 -1
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +4 -3
- data/lib/clacky/default_extensions/ext-studio/ext.yml +1 -1
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +118 -50
- data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +1 -2
- data/lib/clacky/default_extensions/general/agents/general/avatar.png +0 -0
- data/lib/clacky/default_extensions/general/ext.yml +4 -4
- data/lib/clacky/default_extensions/git/ext.yml +0 -1
- data/lib/clacky/default_extensions/git/panels/git/view.js +3 -3
- data/lib/clacky/default_extensions/meeting/ext.yml +0 -1
- data/lib/clacky/default_extensions/meeting/panels/meeting/view.js +1 -2
- data/lib/clacky/default_extensions/time_machine/ext.yml +0 -1
- data/lib/clacky/extension/api_extension.rb +11 -2
- data/lib/clacky/extension/dispatcher.rb +3 -2
- data/lib/clacky/extension/loader.rb +6 -0
- data/lib/clacky/extension/packager.rb +0 -6
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/server/http_server.rb +149 -5
- data/lib/clacky/shell_hook_loader.rb +266 -22
- data/lib/clacky/ui2/components/welcome_banner.rb +1 -1
- data/lib/clacky/utils/workspace_rules.rb +2 -2
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +334 -73
- data/lib/clacky/web/core/ext.js +130 -24
- data/lib/clacky/web/features/extensions/store.js +41 -6
- data/lib/clacky/web/features/extensions/view.js +44 -13
- data/lib/clacky/web/features/new-session/store.js +13 -0
- data/lib/clacky/web/features/new-session/view.js +314 -14
- data/lib/clacky/web/features/trash/view.js +4 -5
- data/lib/clacky/web/i18n.js +44 -2
- data/lib/clacky/web/index.html +54 -16
- data/lib/clacky/web/sessions.js +3 -3
- data/lib/clacky/web/skills.js +86 -48
- data/lib/clacky/web/theme.js +3 -0
- data/lib/clacky/web/ws-dispatcher.js +11 -2
- metadata +7 -7
|
@@ -369,6 +369,8 @@ module Clacky
|
|
|
369
369
|
res.body = html
|
|
370
370
|
elsif req.path.start_with?("/agent_ui/")
|
|
371
371
|
self.send(:serve_agent_ui, req, res)
|
|
372
|
+
elsif req.path.start_with?("/agent_avatar/")
|
|
373
|
+
self.send(:serve_agent_avatar, req, res)
|
|
372
374
|
elsif req.path.start_with?("/ext_ui/")
|
|
373
375
|
self.send(:serve_ext_ui, req, res)
|
|
374
376
|
else
|
|
@@ -538,6 +540,7 @@ module Clacky
|
|
|
538
540
|
when ["GET", "/api/store/skills"] then api_store_skills(res)
|
|
539
541
|
when ["GET", "/api/store/extensions"] then api_store_extensions(req, res)
|
|
540
542
|
when ["GET", "/api/store/extension"] then api_store_extension_detail(req, res)
|
|
543
|
+
when ["POST", "/api/store/extension/install"] then api_store_extension_install(req, res)
|
|
541
544
|
when ["POST", "/api/store/extension/disable"] then api_store_extension_disable(req, res)
|
|
542
545
|
when ["POST", "/api/store/extension/enable"] then api_store_extension_enable(req, res)
|
|
543
546
|
when ["DELETE", "/api/store/extension"] then api_store_extension_uninstall(req, res)
|
|
@@ -623,6 +626,9 @@ module Clacky
|
|
|
623
626
|
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/skills$})
|
|
624
627
|
session_id = path.sub("/api/sessions/", "").sub("/skills", "")
|
|
625
628
|
api_session_skills(session_id, res)
|
|
629
|
+
elsif method == "GET" && path.match?(%r{^/api/agents/[^/]+/skills$})
|
|
630
|
+
agent_id = path[%r{^/api/agents/([^/]+)/skills$}, 1]
|
|
631
|
+
api_agent_skills(agent_id, res)
|
|
626
632
|
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/git/[a-z]+$})
|
|
627
633
|
session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
|
|
628
634
|
action = path[%r{/git/([a-z]+)$}, 1]
|
|
@@ -1061,6 +1067,7 @@ module Clacky
|
|
|
1061
1067
|
private def webui_ext_script_tags
|
|
1062
1068
|
[
|
|
1063
1069
|
panel_agents_script,
|
|
1070
|
+
ext_contributions_script,
|
|
1064
1071
|
agent_webui_script_tags,
|
|
1065
1072
|
container_ext_script_tags,
|
|
1066
1073
|
].reject(&:empty?).join("\n")
|
|
@@ -1097,6 +1104,36 @@ module Clacky
|
|
|
1097
1104
|
"<script>Clacky.ext.registerPanelAgents(#{map.to_json})</script>"
|
|
1098
1105
|
end
|
|
1099
1106
|
|
|
1107
|
+
# Inline script mapping agent id => the panels & skills its extension
|
|
1108
|
+
# contributes (straight from ext.yml). Built-in extensions are excluded so
|
|
1109
|
+
# the new-session page only advertises what a third-party agent adds.
|
|
1110
|
+
private def ext_contributions_script
|
|
1111
|
+
map = ext_contributions_map
|
|
1112
|
+
return "" if map.empty?
|
|
1113
|
+
|
|
1114
|
+
"<script>Clacky.ext.registerAgentContributions(#{map.to_json})</script>"
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
private def ext_contributions_map
|
|
1118
|
+
result = Clacky::ExtensionLoader.load_all
|
|
1119
|
+
by_ext = Hash.new { |h, k| h[k] = { "panels" => [], "skills" => [] } }
|
|
1120
|
+
label = lambda do |unit|
|
|
1121
|
+
{ "id" => unit.id, "title" => unit.spec["title"] || unit.id, "title_zh" => unit.spec["title_zh"] }
|
|
1122
|
+
end
|
|
1123
|
+
|
|
1124
|
+
result.panels.each { |u| by_ext[u.ext_id]["panels"] << label.call(u) unless u.layer == :builtin }
|
|
1125
|
+
result.skills.each { |u| by_ext[u.ext_id]["skills"] << label.call(u) unless u.layer == :builtin }
|
|
1126
|
+
|
|
1127
|
+
out = {}
|
|
1128
|
+
result.agents.each do |u|
|
|
1129
|
+
next if u.layer == :builtin
|
|
1130
|
+
contrib = by_ext[u.ext_id]
|
|
1131
|
+
next if contrib["panels"].empty? && contrib["skills"].empty?
|
|
1132
|
+
out[u.id] = contrib
|
|
1133
|
+
end
|
|
1134
|
+
out
|
|
1135
|
+
end
|
|
1136
|
+
|
|
1100
1137
|
# panel id => list of agent names allowed to see it. Two sources merge:
|
|
1101
1138
|
# 1. `agent.panels: [id]` — agent-side explicit reference (opt-in mount)
|
|
1102
1139
|
# 2. `panel.attach: [...]` — panel-author's default suggestion
|
|
@@ -1283,6 +1320,42 @@ module Clacky
|
|
|
1283
1320
|
serve_static_under(File.join(dir, "webui"), rel, res)
|
|
1284
1321
|
end
|
|
1285
1322
|
|
|
1323
|
+
# GET /agent_avatar/<id> — serve an agent's avatar image (extension
|
|
1324
|
+
# `avatar` file, or a user agent's avatar.png). Read-only, images only.
|
|
1325
|
+
private def serve_agent_avatar(req, res)
|
|
1326
|
+
id = req.path.delete_prefix("/agent_avatar/").split("/").first.to_s
|
|
1327
|
+
return (res.status = 404; res.body = "not found") if id.empty?
|
|
1328
|
+
|
|
1329
|
+
abs = agent_avatar_path(id)
|
|
1330
|
+
unless abs && File.file?(abs)
|
|
1331
|
+
res.status = 404
|
|
1332
|
+
res.body = "not found"
|
|
1333
|
+
return
|
|
1334
|
+
end
|
|
1335
|
+
|
|
1336
|
+
ctype = { ".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg",
|
|
1337
|
+
".webp" => "image/webp", ".svg" => "image/svg+xml", ".gif" => "image/gif" }[File.extname(abs).downcase]
|
|
1338
|
+
unless ctype
|
|
1339
|
+
res.status = 415
|
|
1340
|
+
res.body = "unsupported media type"
|
|
1341
|
+
return
|
|
1342
|
+
end
|
|
1343
|
+
|
|
1344
|
+
res.status = 200
|
|
1345
|
+
res["Content-Type"] = ctype
|
|
1346
|
+
res["Cache-Control"] = "public, max-age=86400"
|
|
1347
|
+
res.body = File.binread(abs)
|
|
1348
|
+
end
|
|
1349
|
+
|
|
1350
|
+
private def agent_avatar_path(id)
|
|
1351
|
+
unit = Clacky::ExtensionLoader.last_result&.agents&.find { |u| u.id == id }
|
|
1352
|
+
avatar_abs = unit&.spec&.[]("avatar_abs").to_s
|
|
1353
|
+
return avatar_abs unless avatar_abs.empty?
|
|
1354
|
+
|
|
1355
|
+
user_png = File.expand_path("~/.clacky/agents/#{id}/avatar.png")
|
|
1356
|
+
File.file?(user_png) ? user_png : nil
|
|
1357
|
+
end
|
|
1358
|
+
|
|
1286
1359
|
# Read-only static serve of `rel` under `root`, JS/CSS/HTML only, with
|
|
1287
1360
|
# strict path containment so a crafted rel cannot escape `root`.
|
|
1288
1361
|
private def serve_static_under(root, rel, res)
|
|
@@ -1419,6 +1492,9 @@ module Clacky
|
|
|
1419
1492
|
output_dir = body["output_dir"].to_s
|
|
1420
1493
|
output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
|
|
1421
1494
|
|
|
1495
|
+
session_id = body["session_id"].to_s
|
|
1496
|
+
session_id = nil if session_id.empty?
|
|
1497
|
+
|
|
1422
1498
|
result = Clacky::Media::Generator.new(@agent_config).generate_image(
|
|
1423
1499
|
prompt: prompt,
|
|
1424
1500
|
aspect_ratio: aspect_ratio,
|
|
@@ -1427,7 +1503,7 @@ module Clacky
|
|
|
1427
1503
|
images: body["images"]
|
|
1428
1504
|
)
|
|
1429
1505
|
if result["success"]
|
|
1430
|
-
log_media_usage(result, prompt: prompt)
|
|
1506
|
+
log_media_usage(result, prompt: prompt, session_id: session_id)
|
|
1431
1507
|
end
|
|
1432
1508
|
status = result["success"] ? 200 : 422
|
|
1433
1509
|
json_response(res, status, result)
|
|
@@ -1451,6 +1527,9 @@ module Clacky
|
|
|
1451
1527
|
output_dir = body["output_dir"].to_s
|
|
1452
1528
|
output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
|
|
1453
1529
|
|
|
1530
|
+
session_id = body["session_id"].to_s
|
|
1531
|
+
session_id = nil if session_id.empty?
|
|
1532
|
+
|
|
1454
1533
|
result = Clacky::Media::Generator.new(@agent_config).generate_video(
|
|
1455
1534
|
prompt: prompt,
|
|
1456
1535
|
aspect_ratio: aspect_ratio,
|
|
@@ -1459,7 +1538,7 @@ module Clacky
|
|
|
1459
1538
|
image: image
|
|
1460
1539
|
)
|
|
1461
1540
|
if result["success"]
|
|
1462
|
-
log_media_usage(result, prompt: prompt)
|
|
1541
|
+
log_media_usage(result, prompt: prompt, session_id: session_id)
|
|
1463
1542
|
end
|
|
1464
1543
|
status = result["success"] ? 200 : 422
|
|
1465
1544
|
json_response(res, status, result)
|
|
@@ -1480,13 +1559,16 @@ module Clacky
|
|
|
1480
1559
|
output_dir = body["output_dir"].to_s
|
|
1481
1560
|
output_dir = @agent_config.default_working_dir || Dir.pwd if output_dir.empty?
|
|
1482
1561
|
|
|
1562
|
+
session_id = body["session_id"].to_s
|
|
1563
|
+
session_id = nil if session_id.empty?
|
|
1564
|
+
|
|
1483
1565
|
result = Clacky::Media::Generator.new(@agent_config).generate_speech(
|
|
1484
1566
|
input: input,
|
|
1485
1567
|
voice: voice,
|
|
1486
1568
|
output_dir: output_dir
|
|
1487
1569
|
)
|
|
1488
1570
|
if result["success"]
|
|
1489
|
-
log_media_usage(result, prompt: input)
|
|
1571
|
+
log_media_usage(result, prompt: input, session_id: session_id)
|
|
1490
1572
|
end
|
|
1491
1573
|
status = result["success"] ? 200 : 422
|
|
1492
1574
|
json_response(res, status, result)
|
|
@@ -1547,7 +1629,7 @@ module Clacky
|
|
|
1547
1629
|
json_response(res, 500, { error: e.message })
|
|
1548
1630
|
end
|
|
1549
1631
|
|
|
1550
|
-
private def log_media_usage(result, prompt:)
|
|
1632
|
+
private def log_media_usage(result, prompt:, session_id: nil)
|
|
1551
1633
|
usage = result["usage"]
|
|
1552
1634
|
cost = result["cost_usd"]
|
|
1553
1635
|
return if usage.nil? && cost.nil?
|
|
@@ -1568,6 +1650,20 @@ module Clacky
|
|
|
1568
1650
|
else "image"
|
|
1569
1651
|
end
|
|
1570
1652
|
Clacky::Logger.info("[Media] #{kind} generated #{parts.join(" ")}")
|
|
1653
|
+
|
|
1654
|
+
require_relative "../billing/billing_store"
|
|
1655
|
+
require_relative "../billing/billing_record"
|
|
1656
|
+
record = Clacky::Billing::BillingRecord.new(
|
|
1657
|
+
session_id: session_id,
|
|
1658
|
+
model: result["model"].to_s,
|
|
1659
|
+
prompt_tokens: usage.is_a?(Hash) ? usage["prompt_tokens"].to_i : 0,
|
|
1660
|
+
completion_tokens: usage.is_a?(Hash) ? usage["completion_tokens"].to_i : 0,
|
|
1661
|
+
cache_read_tokens: usage.is_a?(Hash) ? usage["cache_read_tokens"].to_i : 0,
|
|
1662
|
+
cache_write_tokens: usage.is_a?(Hash) ? usage["cache_write_tokens"].to_i : 0,
|
|
1663
|
+
cost_usd: cost.to_f,
|
|
1664
|
+
cost_source: :api
|
|
1665
|
+
)
|
|
1666
|
+
Clacky::Billing::BillingStore.new.append(record)
|
|
1571
1667
|
end
|
|
1572
1668
|
|
|
1573
1669
|
# GET /api/media/types
|
|
@@ -2324,6 +2420,25 @@ module Clacky
|
|
|
2324
2420
|
end
|
|
2325
2421
|
|
|
2326
2422
|
# DELETE /api/store/extension body: { id: <slug> }
|
|
2423
|
+
def api_store_extension_install(req, res)
|
|
2424
|
+
body = parse_json_body(req)
|
|
2425
|
+
download_url = body["download_url"].to_s.strip
|
|
2426
|
+
name = body["name"].to_s.strip
|
|
2427
|
+
|
|
2428
|
+
if download_url.empty?
|
|
2429
|
+
json_response(res, 400, { ok: false, error: "Missing download_url." })
|
|
2430
|
+
return
|
|
2431
|
+
end
|
|
2432
|
+
|
|
2433
|
+
Clacky::ExtensionPackager.install(download_url, force: true)
|
|
2434
|
+
Clacky::ExtensionLoader.invalidate_cache!
|
|
2435
|
+
json_response(res, 200, { ok: true, name: name })
|
|
2436
|
+
rescue Clacky::ExtensionPackager::Error => e
|
|
2437
|
+
json_response(res, 422, { ok: false, error: e.message })
|
|
2438
|
+
rescue StandardError => e
|
|
2439
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
2440
|
+
end
|
|
2441
|
+
|
|
2327
2442
|
def api_store_extension_uninstall(req, res)
|
|
2328
2443
|
id = parse_json_body(req)["id"].to_s
|
|
2329
2444
|
container = extension_container(id)
|
|
@@ -3917,7 +4032,36 @@ module Clacky
|
|
|
3917
4032
|
json_response(res, 200, { skills: skill_data })
|
|
3918
4033
|
end
|
|
3919
4034
|
|
|
3920
|
-
# GET /api/
|
|
4035
|
+
# GET /api/agents/:id/skills — like api_session_skills but keyed on an
|
|
4036
|
+
# agent id rather than a live session. Used by the New Session page so the
|
|
4037
|
+
# first-message composer can offer slash-command autocomplete before a
|
|
4038
|
+
# session exists.
|
|
4039
|
+
def api_agent_skills(agent_id, res)
|
|
4040
|
+
profile = begin
|
|
4041
|
+
Clacky::AgentProfile.load(agent_id)
|
|
4042
|
+
rescue StandardError
|
|
4043
|
+
nil
|
|
4044
|
+
end
|
|
4045
|
+
|
|
4046
|
+
@skill_loader.load_all
|
|
4047
|
+
skills = @skill_loader.user_invocable_skills
|
|
4048
|
+
skills = skills.select { |s| s.allowed_for_agent?(profile.name) } if profile
|
|
4049
|
+
|
|
4050
|
+
loaded_from = @skill_loader.loaded_from
|
|
4051
|
+
skill_data = skills.map do |skill|
|
|
4052
|
+
{
|
|
4053
|
+
name: skill.identifier,
|
|
4054
|
+
name_zh: skill.name_zh,
|
|
4055
|
+
description: skill.description || skill.context_description,
|
|
4056
|
+
description_zh: skill.description_zh,
|
|
4057
|
+
encrypted: skill.encrypted?,
|
|
4058
|
+
source_type: loaded_from[skill.identifier],
|
|
4059
|
+
always_show: skill.always_show
|
|
4060
|
+
}
|
|
4061
|
+
end
|
|
4062
|
+
|
|
4063
|
+
json_response(res, 200, { skills: skill_data })
|
|
4064
|
+
end
|
|
3921
4065
|
# working_dir. action ∈ status|diff|log|branches. diff accepts ?file=,
|
|
3922
4066
|
# log accepts ?limit=.
|
|
3923
4067
|
def api_session_git(session_id, action, req, res)
|
|
@@ -14,28 +14,48 @@ module Clacky
|
|
|
14
14
|
# hooks.yml format:
|
|
15
15
|
# hooks:
|
|
16
16
|
# before_tool_use:
|
|
17
|
+
# # Simple protocol — no `type` (or `type: command`). exit-code driven.
|
|
17
18
|
# - name: guard # optional label for logs
|
|
18
19
|
# command: "~/.clacky/hook-scripts/guard.sh"
|
|
19
20
|
# timeout: 10 # optional, seconds (default 10)
|
|
21
|
+
#
|
|
22
|
+
# # Rewrite protocol — `type: rewrite`. Rich JSON stdin/stdout, supports
|
|
23
|
+
# # matcher, updatedInput rewrite. (before_tool_use only)
|
|
24
|
+
# - type: rewrite
|
|
25
|
+
# # matcher must be the CANONICAL tool name the agent dispatches
|
|
26
|
+
# # (e.g. `terminal`), NOT an alias (`bash`/`shell`/`exec`): the
|
|
27
|
+
# # agent resolves aliases to canonical names BEFORE firing hooks,
|
|
28
|
+
# # so `matcher: bash` would never match. "*" or omitted = all tools.
|
|
29
|
+
# matcher: terminal
|
|
30
|
+
# command: "~/.clacky/hook-scripts/rewrite.sh"
|
|
31
|
+
# timeout: 30 # optional, seconds (default 60)
|
|
20
32
|
# on_complete:
|
|
21
33
|
# - command: "notify-send done"
|
|
22
34
|
#
|
|
23
35
|
# Runtime contract (per invocation):
|
|
24
36
|
# - The event payload is passed to the command as JSON on STDIN.
|
|
25
|
-
# - exit 0 → allow (default).
|
|
26
|
-
# - exit 2 → deny; STDOUT
|
|
27
|
-
# before_tool_use
|
|
37
|
+
# - exit 0 → allow (default). Rewrite hooks parse STDOUT as hookSpecificOutput.
|
|
38
|
+
# - exit 2 → deny; STDOUT (simple) or stderr/stdout (rewrite) is the reason.
|
|
39
|
+
# Only before_tool_use is checked for {action: :deny}.
|
|
28
40
|
# - any other exit / timeout / crash → logged, treated as allow (a broken
|
|
29
41
|
# hook must never wedge the agent).
|
|
42
|
+
#
|
|
43
|
+
# Rewrite entries chain in config order: each applies its updatedInput IN
|
|
44
|
+
# PLACE on the tool call (complete replacement — no merge); the first deny
|
|
45
|
+
# stops the chain.
|
|
30
46
|
class ShellHookLoader
|
|
31
|
-
DEFAULT_PATH
|
|
32
|
-
DEFAULT_TIMEOUT
|
|
33
|
-
|
|
47
|
+
DEFAULT_PATH = File.expand_path("~/.clacky/hooks.yml")
|
|
48
|
+
DEFAULT_TIMEOUT = 10
|
|
49
|
+
REWRITE_DEFAULT_TIMEOUT = 60
|
|
50
|
+
DENY_EXIT_CODE = 2
|
|
34
51
|
|
|
35
52
|
Result = Struct.new(:registered, :skipped, keyword_init: true)
|
|
36
53
|
|
|
37
|
-
|
|
38
|
-
|
|
54
|
+
# The context procs supply rewrite-protocol context at run time; optional so
|
|
55
|
+
# simple-only callers (e.g. `clacky hook_verify`) can omit them.
|
|
56
|
+
def self.load_into(hook_manager, session_id_fn: nil, cwd_fn: nil, permission_mode_fn: nil, path: DEFAULT_PATH)
|
|
57
|
+
new(path: path, session_id_fn: session_id_fn, cwd_fn: cwd_fn, permission_mode_fn: permission_mode_fn)
|
|
58
|
+
.load_into(hook_manager)
|
|
39
59
|
end
|
|
40
60
|
|
|
41
61
|
# Create a starter hooks.yml plus an example guard script. Idempotent-ish:
|
|
@@ -67,12 +87,18 @@ module Clacky
|
|
|
67
87
|
File.write(path, <<~YAML)
|
|
68
88
|
# Declarative shell hooks. Each command receives the event payload as JSON
|
|
69
89
|
# on STDIN. For before_tool_use: exit 2 = deny (STDOUT = reason), exit 0 = allow.
|
|
90
|
+
# Add `type: rewrite` to a before_tool_use entry to use the rich JSON
|
|
91
|
+
# protocol (updatedInput rewrite, matcher).
|
|
70
92
|
# Events: #{HookManager::HOOK_EVENTS.join(", ")}
|
|
71
93
|
hooks:
|
|
72
94
|
before_tool_use:
|
|
73
95
|
- name: deny-example
|
|
74
96
|
command: "#{guard}"
|
|
75
97
|
timeout: 10
|
|
98
|
+
# - type: rewrite
|
|
99
|
+
# matcher: terminal
|
|
100
|
+
# command: "~/.clacky/hook-scripts/rewrite.sh"
|
|
101
|
+
# timeout: 30
|
|
76
102
|
# on_complete:
|
|
77
103
|
# - command: "echo task finished"
|
|
78
104
|
YAML
|
|
@@ -80,8 +106,11 @@ module Clacky
|
|
|
80
106
|
path
|
|
81
107
|
end
|
|
82
108
|
|
|
83
|
-
def initialize(path: DEFAULT_PATH)
|
|
84
|
-
@path
|
|
109
|
+
def initialize(path: DEFAULT_PATH, session_id_fn: nil, cwd_fn: nil, permission_mode_fn: nil)
|
|
110
|
+
@path = path
|
|
111
|
+
@session_id_fn = session_id_fn
|
|
112
|
+
@cwd_fn = cwd_fn
|
|
113
|
+
@permission_mode_fn = permission_mode_fn
|
|
85
114
|
end
|
|
86
115
|
|
|
87
116
|
# @return [Result] counts of registered hooks and skipped (with reasons)
|
|
@@ -106,14 +135,37 @@ module Clacky
|
|
|
106
135
|
end
|
|
107
136
|
|
|
108
137
|
private def register_one(hook_manager, event, spec, result)
|
|
138
|
+
if spec["type"] == "rewrite" && event != :before_tool_use
|
|
139
|
+
# type: rewrite only makes sense under before_tool_use (it rewrites tool
|
|
140
|
+
# input); elsewhere matcher/updatedInput would be silently ignored —
|
|
141
|
+
# skip + warn so the misconfiguration is visible.
|
|
142
|
+
name = (spec["name"] || spec["command"] || "rewrite hook").to_s
|
|
143
|
+
result.skipped << [name, "type: rewrite is only valid under before_tool_use"]
|
|
144
|
+
elsif spec["type"] == "rewrite"
|
|
145
|
+
register_rewrite(hook_manager, spec, result)
|
|
146
|
+
else
|
|
147
|
+
register_simple(hook_manager, event, spec, result)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Returns [name, command] for a spec, or nil (after recording a skip) when
|
|
152
|
+
# the command is missing. Shared by the simple and rewrite registrars.
|
|
153
|
+
private def resolve_command(spec, result)
|
|
109
154
|
command = spec["command"].to_s.strip
|
|
110
155
|
name = spec["name"] || command
|
|
111
|
-
timeout = (spec["timeout"] || DEFAULT_TIMEOUT).to_i
|
|
112
|
-
|
|
113
156
|
if command.empty?
|
|
114
157
|
result.skipped << [name, "missing command"]
|
|
115
|
-
|
|
158
|
+
nil
|
|
159
|
+
else
|
|
160
|
+
[name, command]
|
|
116
161
|
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private def register_simple(hook_manager, event, spec, result)
|
|
165
|
+
resolved = resolve_command(spec, result)
|
|
166
|
+
return unless resolved
|
|
167
|
+
name, command = resolved
|
|
168
|
+
timeout = (spec["timeout"] || DEFAULT_TIMEOUT).to_i
|
|
117
169
|
|
|
118
170
|
unless HookManager::HOOK_EVENTS.include?(event)
|
|
119
171
|
result.skipped << [name, "unknown event: #{event}"]
|
|
@@ -126,25 +178,123 @@ module Clacky
|
|
|
126
178
|
result.registered << [event, name]
|
|
127
179
|
end
|
|
128
180
|
|
|
129
|
-
|
|
130
|
-
|
|
181
|
+
# Rewrite protocol (before_tool_use only): rich JSON stdin/stdout.
|
|
182
|
+
private def register_rewrite(hook_manager, spec, result)
|
|
183
|
+
resolved = resolve_command(spec, result)
|
|
184
|
+
return unless resolved
|
|
185
|
+
name, command = resolved
|
|
186
|
+
timeout = (spec["timeout"] || REWRITE_DEFAULT_TIMEOUT).to_i
|
|
187
|
+
matcher = spec["matcher"]
|
|
131
188
|
|
|
132
|
-
|
|
189
|
+
hook_manager.add(:before_tool_use) do |call|
|
|
190
|
+
tool_name = (call[:name] || call["name"]).to_s
|
|
191
|
+
next { action: :allow } unless matcher_applies?(matcher, tool_name)
|
|
192
|
+
run_rewrite(command, timeout, call, tool_name)
|
|
193
|
+
end
|
|
194
|
+
result.registered << [:before_tool_use, name]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# "*" or omitted matches every tool; otherwise the matcher must equal the
|
|
198
|
+
# canonical tool name exactly (no aliases, regex, or lists).
|
|
199
|
+
private def matcher_applies?(matcher, tool_name)
|
|
200
|
+
matcher.nil? || matcher == "*" || matcher == tool_name
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Cap on bytes buffered per stream: before_tool_use fires on every tool
|
|
204
|
+
# call, so a runaway/malicious hook could otherwise OOM the agent.
|
|
205
|
+
MAX_OUTPUT_BYTES = 8 * 1024 * 1024
|
|
206
|
+
|
|
207
|
+
# Spawn `command` in its own process group, feed `payload` on STDIN, and
|
|
208
|
+
# return [stdout, stderr, status]. stdout and stderr are drained on parallel
|
|
209
|
+
# reader threads — a single sequential reader deadlocks once a child fills
|
|
210
|
+
# the 64KB stderr pipe while keeping stdout open. The dedicated process
|
|
211
|
+
# group lets us SIGKILL the whole tree on timeout (including grandchildren
|
|
212
|
+
# that inherit the fds), so a hook that traps TERM can't hang the agent.
|
|
213
|
+
# Output is scrubbed to valid UTF-8 so a stray non-UTF-8 byte can't turn a
|
|
214
|
+
# deny into an allow via a .strip / JSON.parse raise.
|
|
215
|
+
private def capture_streams(command, payload, timeout)
|
|
216
|
+
stdout = +""
|
|
217
|
+
stderr = +""
|
|
218
|
+
# Declared here so the block assigns this var, not a block-local one
|
|
219
|
+
# (else every deny silently becomes an allow).
|
|
133
220
|
status = nil
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
221
|
+
|
|
222
|
+
Open3.popen3(command, pgroup: true) do |stdin, out, err, wait_thr|
|
|
223
|
+
pgid = wait_thr.pid
|
|
224
|
+
|
|
225
|
+
out_reader = Thread.new { drain_stream(out, stdout) }
|
|
226
|
+
err_reader = Thread.new { drain_stream(err, stderr) }
|
|
227
|
+
|
|
228
|
+
# Write stdin on its own thread: a child that doesn't read stdin (or a
|
|
229
|
+
# payload larger than the ~64KB pipe buffer) would otherwise block the
|
|
230
|
+
# main thread before it reaches wait_thr.join(timeout) — the only thing
|
|
231
|
+
# enforcing the timeout — and wedge capture_streams forever.
|
|
232
|
+
writer = Thread.new do
|
|
233
|
+
begin
|
|
234
|
+
stdin.write(payload)
|
|
235
|
+
rescue Errno::EPIPE
|
|
236
|
+
# Child already exited / stopped reading; its stdout may still be readable.
|
|
237
|
+
rescue IOError
|
|
238
|
+
# Pipes closed (timeout path) — nothing more to write.
|
|
239
|
+
ensure
|
|
240
|
+
stdin.close rescue nil
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
137
244
|
if wait_thr.join(timeout)
|
|
138
|
-
out = stdout.read
|
|
139
245
|
status = wait_thr.value
|
|
246
|
+
# Child exited; drain the readers (bounded join — a grandchild
|
|
247
|
+
# holding the pipe fd can keep them from EOF; see drain_stream).
|
|
248
|
+
out_reader.join(2)
|
|
249
|
+
err_reader.join(2)
|
|
140
250
|
else
|
|
141
|
-
|
|
251
|
+
# SIGTERM, then SIGKILL the whole group if it won't die. popen3's
|
|
252
|
+
# ensure joins wait_thr without a timeout, so the child MUST be dead
|
|
253
|
+
# before we leave the block — a hook that traps/ignores TERM can't be
|
|
254
|
+
# allowed to hang here.
|
|
255
|
+
Process.kill("TERM", -pgid) rescue nil
|
|
256
|
+
unless wait_thr.join(1)
|
|
257
|
+
Process.kill("KILL", -pgid) rescue nil
|
|
258
|
+
wait_thr.join
|
|
259
|
+
end
|
|
260
|
+
out_reader.join(1)
|
|
261
|
+
err_reader.join(1)
|
|
262
|
+
writer.kill rescue nil
|
|
142
263
|
raise Timeout::Error
|
|
143
264
|
end
|
|
144
265
|
end
|
|
145
266
|
|
|
267
|
+
[stdout.scrub!, stderr.scrub!, status]
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Read `io` into `buf` in readpartial chunks until EOF, instead of a single
|
|
271
|
+
# IO#read. readpartial returns whatever is currently available, so a
|
|
272
|
+
# grandchild that inherited the pipe fd (and thus keeps it from EOF) can no
|
|
273
|
+
# longer block us forever — already-read bytes are preserved in `buf`.
|
|
274
|
+
# Appending stops at MAX_OUTPUT_BYTES so a runaway hook can't OOM the agent;
|
|
275
|
+
# we keep draining past the cap so the child doesn't block on a full pipe.
|
|
276
|
+
private def drain_stream(io, buf)
|
|
277
|
+
loop do
|
|
278
|
+
chunk = io.readpartial(4096)
|
|
279
|
+
buf << chunk if buf.bytesize < MAX_OUTPUT_BYTES
|
|
280
|
+
end
|
|
281
|
+
rescue EOFError, IOError
|
|
282
|
+
# EOFError: pipe closed (normal end). IOError: pipes closed on block exit.
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Strip + default, so a deny never has a blank reason.
|
|
286
|
+
private def deny_reason(raw)
|
|
287
|
+
s = raw.to_s.strip
|
|
288
|
+
s.empty? ? "Denied by hook" : s
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
private def run_command(event, command, timeout, args)
|
|
292
|
+
payload = JSON.generate(build_payload(event, args))
|
|
293
|
+
|
|
294
|
+
out, _err, status = capture_streams(command, payload, timeout)
|
|
295
|
+
|
|
146
296
|
if status&.exitstatus == DENY_EXIT_CODE
|
|
147
|
-
{ action: :deny, reason: out
|
|
297
|
+
{ action: :deny, reason: deny_reason(out) }
|
|
148
298
|
else
|
|
149
299
|
{ action: :allow }
|
|
150
300
|
end
|
|
@@ -177,5 +327,99 @@ module Clacky
|
|
|
177
327
|
|
|
178
328
|
base
|
|
179
329
|
end
|
|
330
|
+
|
|
331
|
+
# --- Rewrite protocol (type: rewrite) -------------------------------------
|
|
332
|
+
|
|
333
|
+
private def run_rewrite(command, timeout, call, tool_name)
|
|
334
|
+
tool_input = parse_tool_args(call)
|
|
335
|
+
payload = build_rewrite_payload(tool_name, tool_input)
|
|
336
|
+
|
|
337
|
+
stdout, stderr, status = capture_streams(command, payload, timeout)
|
|
338
|
+
|
|
339
|
+
# nil exitstatus = killed by signal (SIGKILL/OOM) — a crash, not a clean
|
|
340
|
+
# exit. Treat as allow so a partial deny payload can't deny from garbage.
|
|
341
|
+
if status&.exitstatus.nil?
|
|
342
|
+
Clacky::Logger.warn("[ShellHookLoader] Rewrite hook '#{command}' died (signal) — allowing")
|
|
343
|
+
return { action: :allow }
|
|
344
|
+
end
|
|
345
|
+
exit_code = status.exitstatus
|
|
346
|
+
|
|
347
|
+
if exit_code == DENY_EXIT_CODE
|
|
348
|
+
source = stderr.strip.empty? ? stdout : stderr
|
|
349
|
+
return { action: :deny, reason: deny_reason(source) }
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
if exit_code != 0
|
|
353
|
+
Clacky::Logger.warn("[ShellHookLoader] Rewrite hook '#{command}' exited #{exit_code} (non-blocking) — allowing")
|
|
354
|
+
return { action: :allow }
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
result = parse_hook_output(stdout)
|
|
358
|
+
# Chained rewrite: apply updatedInput IN PLACE on `call` so the next
|
|
359
|
+
# rewrite sees it. updatedInput is a complete replacement (no merge);
|
|
360
|
+
# empty/absent means "no rewrite this time" — leave `call` untouched.
|
|
361
|
+
updated = result.delete(:updated_input)
|
|
362
|
+
call[:arguments] = JSON.generate(updated) if updated.is_a?(Hash) && !updated.empty?
|
|
363
|
+
result
|
|
364
|
+
rescue Timeout::Error
|
|
365
|
+
Clacky::Logger.warn("[ShellHookLoader] Rewrite hook '#{command}' timed out after #{timeout}s — allowing")
|
|
366
|
+
{ action: :allow }
|
|
367
|
+
rescue StandardError => e
|
|
368
|
+
Clacky::Logger.warn("[ShellHookLoader] Rewrite hook '#{command}' failed: #{e.message} — allowing")
|
|
369
|
+
{ action: :allow }
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
private def build_rewrite_payload(tool_name, tool_input)
|
|
373
|
+
JSON.generate({
|
|
374
|
+
session_id: @session_id_fn&.call,
|
|
375
|
+
cwd: @cwd_fn&.call || Dir.pwd,
|
|
376
|
+
permission_mode: @permission_mode_fn&.call || "default",
|
|
377
|
+
hook_event_name: "PreToolUse",
|
|
378
|
+
tool_name: tool_name,
|
|
379
|
+
tool_input: tool_input
|
|
380
|
+
})
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Parse the tool arguments the hook should see. Unparseable JSON is logged
|
|
384
|
+
# (not silently swallowed) but defers to an empty Hash so a broken payload
|
|
385
|
+
# doesn't wedge the agent.
|
|
386
|
+
private def parse_tool_args(call)
|
|
387
|
+
args = call[:arguments] || call["arguments"]
|
|
388
|
+
case args
|
|
389
|
+
when String then JSON.parse(args)
|
|
390
|
+
when Hash then args
|
|
391
|
+
else {}
|
|
392
|
+
end
|
|
393
|
+
rescue JSON::ParserError => e
|
|
394
|
+
Clacky::Logger.warn("[ShellHookLoader] Unparseable tool arguments (#{e.message}); hook sees empty input")
|
|
395
|
+
{}
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Missing or unparseable hookSpecificOutput defers to allow.
|
|
399
|
+
private def parse_hook_output(stdout)
|
|
400
|
+
return { action: :allow } if stdout.nil? || stdout.strip.empty?
|
|
401
|
+
|
|
402
|
+
parsed = JSON.parse(stdout.strip)
|
|
403
|
+
specific = parsed.is_a?(Hash) && parsed["hookSpecificOutput"]
|
|
404
|
+
return { action: :allow } unless specific.is_a?(Hash)
|
|
405
|
+
|
|
406
|
+
decision = specific["permissionDecision"]
|
|
407
|
+
reason = specific["permissionDecisionReason"]
|
|
408
|
+
updated_input = specific["updatedInput"]
|
|
409
|
+
|
|
410
|
+
# Permission decision other than "deny" is treated as allow: the agent's
|
|
411
|
+
# own permission_mode governs confirmation, not the hook.
|
|
412
|
+
result = case decision
|
|
413
|
+
when "deny"
|
|
414
|
+
{ action: :deny, reason: deny_reason(reason) }
|
|
415
|
+
else
|
|
416
|
+
{ action: :allow } # "allow", "ask", "defer", unknown
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
result[:updated_input] = updated_input if updated_input.is_a?(Hash)
|
|
420
|
+
result
|
|
421
|
+
rescue JSON::ParserError
|
|
422
|
+
{ action: :allow }
|
|
423
|
+
end
|
|
180
424
|
end
|
|
181
425
|
end
|
|
@@ -28,7 +28,7 @@ module Clacky
|
|
|
28
28
|
TIPS = [
|
|
29
29
|
"[*] Ask questions, edit files, or run commands",
|
|
30
30
|
"[*] Be specific for the best results",
|
|
31
|
-
"[*] Create .clackyrules to customize interactions",
|
|
31
|
+
"[*] Create .clackyrules or AGENTS.md to customize interactions",
|
|
32
32
|
"[*] Type /help for more commands"
|
|
33
33
|
].freeze
|
|
34
34
|
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Clacky
|
|
4
4
|
module Utils
|
|
5
|
-
# Discovers
|
|
5
|
+
# Discovers project rules files in the workspace.
|
|
6
6
|
# Used by both SystemPromptBuilder (rules injection) and WelcomeBanner (UI display).
|
|
7
7
|
module WorkspaceRules
|
|
8
|
-
RULES_FILENAMES = [".clackyrules", ".cursorrules", "CLAUDE.md"].freeze
|
|
8
|
+
RULES_FILENAMES = [".clackyrules", "AGENTS.md", ".cursorrules", "CLAUDE.md"].freeze
|
|
9
9
|
SUB_PROJECT_SUMMARY_LINES = 5
|
|
10
10
|
|
|
11
11
|
# Find the main rules file in the given directory.
|
data/lib/clacky/version.rb
CHANGED