openclacky 1.2.7 → 1.2.8
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 +14 -0
- data/lib/clacky/agent.rb +3 -0
- data/lib/clacky/billing/billing_store.rb +107 -3
- data/lib/clacky/cli.rb +105 -0
- data/lib/clacky/client.rb +32 -3
- 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/patch_loader.rb +282 -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 +26 -5
- data/lib/clacky/server/scheduler.rb +1 -4
- data/lib/clacky/shell_hook_loader.rb +181 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +155 -13
- data/lib/clacky/web/billing.js +117 -22
- data/lib/clacky/web/i18n.js +26 -6
- data/lib/clacky.rb +6 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '080944ed788d584c01c97ba01a27a63b2d2ab341ecf88140a63424460dcb105e'
|
|
4
|
+
data.tar.gz: 3c2b51bde81be7c18b3384297609f0163d5e6ed40de7121185a4cc374e576f10
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c21e88b443f05ae75979ca4b890142ad15283382ba39fba434a3478f9f3a7f08b13c50fd5760d128662dee26374d69695e6b35e0d25e8f92cdad7351517f564f
|
|
7
|
+
data.tar.gz: fd37b0b3d64bc68ac777c84beef22be2c6f10c260b5e959273b953aae739c1f176bce7dae5c01c738acb332c5eded97eb973250f51576accbce58ee5f9e12139
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.2.8] - 2026-06-01
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Extensibility framework: patching, shell hooks, and channel user adapter plugins — customize Clacky behavior without modifying core code
|
|
12
|
+
|
|
13
|
+
### Improved
|
|
14
|
+
- Billing session list now shows session names, merged deleted sessions, and standardized token breakdown with cache hit/miss color coding
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- Streaming LLM responses automatically retry when connection drops instead of silently truncating
|
|
18
|
+
|
|
19
|
+
### More
|
|
20
|
+
- Extend openclacky skill with additional extension points
|
|
21
|
+
|
|
8
22
|
## [1.2.7] - 2026-06-01
|
|
9
23
|
|
|
10
24
|
### Added
|
data/lib/clacky/agent.rb
CHANGED
|
@@ -137,6 +137,9 @@ module Clacky
|
|
|
137
137
|
# Register built-in tools
|
|
138
138
|
register_builtin_tools
|
|
139
139
|
|
|
140
|
+
# Load declarative shell hooks from ~/.clacky/hooks.yml
|
|
141
|
+
ShellHookLoader.load_into(@hooks)
|
|
142
|
+
|
|
140
143
|
# Ensure user-space parsers are in place (~/.clacky/parsers/)
|
|
141
144
|
Utils::ParserManager.setup!
|
|
142
145
|
|
|
@@ -4,7 +4,7 @@ require "json"
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require "securerandom"
|
|
6
6
|
require_relative "billing_record"
|
|
7
|
-
|
|
7
|
+
require_relative "../session_manager"
|
|
8
8
|
module Clacky
|
|
9
9
|
module Billing
|
|
10
10
|
# Persistent storage for billing records using JSONL files
|
|
@@ -115,8 +115,112 @@ module Clacky
|
|
|
115
115
|
}
|
|
116
116
|
end
|
|
117
117
|
|
|
118
|
-
# Get
|
|
119
|
-
# @param
|
|
118
|
+
# Get session-level summary statistics
|
|
119
|
+
# @param period [Symbol] :day, :week, :month, :year, or :all
|
|
120
|
+
# @param model [String, nil] Filter by model name
|
|
121
|
+
# @param limit [Integer] Maximum number of sessions to return
|
|
122
|
+
# @return [Array<Hash>] Session summaries sorted by cost descending
|
|
123
|
+
def session_summary(period: :month, model: nil, limit: 50)
|
|
124
|
+
from_time = period_start(period)
|
|
125
|
+
records = query(from: from_time, model: model)
|
|
126
|
+
|
|
127
|
+
# Load session names from session manager
|
|
128
|
+
session_names = load_session_names
|
|
129
|
+
|
|
130
|
+
# Group by session_id
|
|
131
|
+
by_session = records.group_by { |r| r.session_id || "unknown" }
|
|
132
|
+
|
|
133
|
+
active_sessions = []
|
|
134
|
+
deleted_records = []
|
|
135
|
+
|
|
136
|
+
by_session.each do |session_id, rs|
|
|
137
|
+
total_cost = rs.sum { |r| r.cost_usd || 0 }
|
|
138
|
+
total_prompt = rs.sum { |r| r.prompt_tokens || 0 }
|
|
139
|
+
total_completion = rs.sum { |r| r.completion_tokens || 0 }
|
|
140
|
+
total_cache_read = rs.sum { |r| r.cache_read_tokens || 0 }
|
|
141
|
+
total_cache_write = rs.sum { |r| r.cache_write_tokens || 0 }
|
|
142
|
+
first_record = rs.min_by { |r| r.timestamp }
|
|
143
|
+
last_record = rs.max_by { |r| r.timestamp }
|
|
144
|
+
|
|
145
|
+
entry = {
|
|
146
|
+
session_id: session_id,
|
|
147
|
+
session_name: session_names[session_id],
|
|
148
|
+
total_cost: total_cost.round(6),
|
|
149
|
+
total_tokens: total_prompt + total_completion,
|
|
150
|
+
prompt_tokens: total_prompt,
|
|
151
|
+
completion_tokens: total_completion,
|
|
152
|
+
cache_read_tokens: total_cache_read,
|
|
153
|
+
cache_write_tokens: total_cache_write,
|
|
154
|
+
requests: rs.size,
|
|
155
|
+
first_request: first_record&.timestamp&.iso8601,
|
|
156
|
+
last_request: last_record&.timestamp&.iso8601,
|
|
157
|
+
models: rs.map(&:model).uniq
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if session_names[session_id]
|
|
161
|
+
active_sessions << entry
|
|
162
|
+
else
|
|
163
|
+
deleted_records << entry
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Merge all deleted sessions into a single row
|
|
168
|
+
if deleted_records.any?
|
|
169
|
+
merged = {
|
|
170
|
+
session_id: "_deleted_",
|
|
171
|
+
session_name: nil,
|
|
172
|
+
is_deleted: true,
|
|
173
|
+
total_cost: deleted_records.sum { |r| r[:total_cost] }.round(6),
|
|
174
|
+
total_tokens: deleted_records.sum { |r| r[:total_tokens] },
|
|
175
|
+
prompt_tokens: deleted_records.sum { |r| r[:prompt_tokens] },
|
|
176
|
+
completion_tokens: deleted_records.sum { |r| r[:completion_tokens] },
|
|
177
|
+
cache_read_tokens: deleted_records.sum { |r| r[:cache_read_tokens] },
|
|
178
|
+
cache_write_tokens: deleted_records.sum { |r| r[:cache_write_tokens] },
|
|
179
|
+
requests: deleted_records.sum { |r| r[:requests] },
|
|
180
|
+
first_request: deleted_records.map { |r| r[:first_request] }.compact.min,
|
|
181
|
+
last_request: deleted_records.map { |r| r[:last_request] }.compact.max,
|
|
182
|
+
models: deleted_records.flat_map { |r| r[:models] }.uniq
|
|
183
|
+
}
|
|
184
|
+
active_sessions << merged
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Sort by total cost descending
|
|
188
|
+
active_sessions.sort_by! { |s| -s[:total_cost] }
|
|
189
|
+
|
|
190
|
+
# Apply limit
|
|
191
|
+
limit ? active_sessions.first(limit) : active_sessions
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Load session names from session manager (including trashed sessions)
|
|
195
|
+
# Returns a hash mapping session_id to session name
|
|
196
|
+
def load_session_names
|
|
197
|
+
names = {}
|
|
198
|
+
begin
|
|
199
|
+
# Load from active sessions
|
|
200
|
+
manager = Clacky::SessionManager.new
|
|
201
|
+
manager.all_sessions.each do |session|
|
|
202
|
+
id = session[:session_id]
|
|
203
|
+
name = session[:name]
|
|
204
|
+
names[id] = name if id && name && !name.to_s.empty?
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Also load from trashed sessions
|
|
208
|
+
trash_dir = File.join(Dir.home, ".clacky", "trash", "sessions-trash")
|
|
209
|
+
if Dir.exist?(trash_dir)
|
|
210
|
+
Dir.glob(File.join(trash_dir, "*.json")).each do |filepath|
|
|
211
|
+
session = JSON.parse(File.read(filepath), symbolize_names: true) rescue next
|
|
212
|
+
id = session[:session_id]
|
|
213
|
+
name = session[:name]
|
|
214
|
+
names[id] = name if id && name && !name.to_s.empty?
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
rescue => e
|
|
218
|
+
# Silently fail if session manager is not available
|
|
219
|
+
end
|
|
220
|
+
names
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Get daily cost breakdown for the last N days # @param days [Integer] Number of days to include
|
|
120
224
|
# @param model [String, nil] Filter by model name
|
|
121
225
|
# @return [Array<Hash>] Daily summaries with date and cost
|
|
122
226
|
def daily_breakdown(days: 30, model: nil)
|
data/lib/clacky/cli.rb
CHANGED
|
@@ -942,6 +942,111 @@ module Clacky
|
|
|
942
942
|
end
|
|
943
943
|
|
|
944
944
|
# ── billing command ────────────────────────────────────────────────────────
|
|
945
|
+
desc "patch_new ID TARGET", "Scaffold a runtime patch for a method (TARGET like Clacky::Tools::WebSearch#execute)"
|
|
946
|
+
long_desc <<-LONGDESC
|
|
947
|
+
Generate a method-override patch under ~/.clacky/patches/ID/. The current
|
|
948
|
+
method fingerprint is computed automatically and stored in meta.yml; you
|
|
949
|
+
only edit the method body in patch.rb. If a future gem version changes the
|
|
950
|
+
targeted method, the fingerprint will no longer match and the patch is
|
|
951
|
+
auto-disabled on next start (rather than applied and risking breakage).
|
|
952
|
+
|
|
953
|
+
Examples:
|
|
954
|
+
$ clacky patch_new fix-search Clacky::Tools::WebSearch#execute -d "bump timeout"
|
|
955
|
+
LONGDESC
|
|
956
|
+
option :desc, type: :string, aliases: "-d", default: "", desc: "Short description"
|
|
957
|
+
def patch_new(id, target)
|
|
958
|
+
require_relative "patch_loader"
|
|
959
|
+
path = Clacky::PatchLoader.scaffold(id, target, description: options[:desc])
|
|
960
|
+
puts "Created patch: #{path}"
|
|
961
|
+
puts "Edit patch.rb, then run: clacky patch_verify"
|
|
962
|
+
rescue ArgumentError, StandardError => e
|
|
963
|
+
warn "Error: #{e.message}"
|
|
964
|
+
exit 1
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
desc "patch_verify", "Load ~/.clacky/patches/ and report applied / disabled / skipped"
|
|
968
|
+
def patch_verify
|
|
969
|
+
require "clacky"
|
|
970
|
+
result = Clacky::PatchLoader.last_result
|
|
971
|
+
|
|
972
|
+
if result.applied.empty? && result.disabled.empty? && result.skipped.empty?
|
|
973
|
+
puts "No patches found in ~/.clacky/patches/"
|
|
974
|
+
return
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
result.applied.each { |id| puts "[OK] #{id}" }
|
|
978
|
+
result.disabled.each { |(id, reason)| puts "[DISABLED] #{id} — #{reason}" }
|
|
979
|
+
result.skipped.each { |(id, reason)| puts "[SKIP] #{id} — #{reason}" }
|
|
980
|
+
exit 1 if result.skipped.any?
|
|
981
|
+
end
|
|
982
|
+
|
|
983
|
+
desc "patch_list", "List patches under ~/.clacky/patches/ and their status"
|
|
984
|
+
def patch_list
|
|
985
|
+
invoke :patch_verify, []
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
desc "hook_new", "Scaffold a starter ~/.clacky/hooks.yml with an example guard script"
|
|
989
|
+
def hook_new
|
|
990
|
+
require_relative "shell_hook_loader"
|
|
991
|
+
path = Clacky::ShellHookLoader.scaffold
|
|
992
|
+
puts "Created hooks config: #{path}"
|
|
993
|
+
puts "Edit it, then run: clacky hook_verify"
|
|
994
|
+
rescue ArgumentError => e
|
|
995
|
+
warn "Error: #{e.message}"
|
|
996
|
+
exit 1
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
desc "hook_verify", "Load ~/.clacky/hooks.yml and report which hooks register"
|
|
1000
|
+
def hook_verify
|
|
1001
|
+
require_relative "agent/hook_manager"
|
|
1002
|
+
require_relative "shell_hook_loader"
|
|
1003
|
+
hm = Clacky::HookManager.new
|
|
1004
|
+
result = Clacky::ShellHookLoader.load_into(hm)
|
|
1005
|
+
|
|
1006
|
+
if result.registered.empty? && result.skipped.empty?
|
|
1007
|
+
puts "No hooks found in ~/.clacky/hooks.yml"
|
|
1008
|
+
return
|
|
1009
|
+
end
|
|
1010
|
+
|
|
1011
|
+
result.registered.each { |(event, name)| puts "[OK] #{event} → #{name}" }
|
|
1012
|
+
result.skipped.each { |(name, reason)| puts "[SKIP] #{name} — #{reason}" }
|
|
1013
|
+
exit 1 if result.skipped.any?
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
desc "channel_new NAME", "Scaffold a custom channel adapter at ~/.clacky/channels/NAME/"
|
|
1017
|
+
long_desc <<-LONGDESC
|
|
1018
|
+
Generate a ready-to-edit channel adapter skeleton. The skeleton already
|
|
1019
|
+
self-registers and implements the full adapter interface with TODO markers —
|
|
1020
|
+
you only fill in the method bodies, then run `clacky channel_verify`.
|
|
1021
|
+
|
|
1022
|
+
Examples:
|
|
1023
|
+
$ clacky channel_new slack
|
|
1024
|
+
LONGDESC
|
|
1025
|
+
def channel_new(name)
|
|
1026
|
+
require_relative "server/channel"
|
|
1027
|
+
path = Clacky::Channel::Adapters::UserAdapterLoader.scaffold(name)
|
|
1028
|
+
puts "Created channel adapter: #{path}"
|
|
1029
|
+
puts "Edit the TODO sections, then run: clacky channel_verify"
|
|
1030
|
+
rescue ArgumentError => e
|
|
1031
|
+
warn "Error: #{e.message}"
|
|
1032
|
+
exit 1
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
desc "channel_verify", "Load user channel adapters and report which are valid"
|
|
1036
|
+
def channel_verify
|
|
1037
|
+
require_relative "server/channel"
|
|
1038
|
+
result = Clacky::Channel::Adapters::UserAdapterLoader.last_result
|
|
1039
|
+
|
|
1040
|
+
if result.loaded.empty? && result.skipped.empty?
|
|
1041
|
+
puts "No custom channel adapters found in ~/.clacky/channels/"
|
|
1042
|
+
return
|
|
1043
|
+
end
|
|
1044
|
+
|
|
1045
|
+
result.loaded.each { |n| puts "[OK] #{n}" }
|
|
1046
|
+
result.skipped.each { |(n, reason)| puts "[SKIP] #{n} — #{reason}" }
|
|
1047
|
+
exit 1 if result.skipped.any?
|
|
1048
|
+
end
|
|
1049
|
+
|
|
945
1050
|
desc "billing", "Show billing summary and usage statistics"
|
|
946
1051
|
long_desc <<-LONGDESC
|
|
947
1052
|
Display billing summary with token usage and cost breakdown.
|
data/lib/clacky/client.rb
CHANGED
|
@@ -258,7 +258,16 @@ module Clacky
|
|
|
258
258
|
response.env.body = sse_buf if response.body.to_s.empty?
|
|
259
259
|
raise_error(response)
|
|
260
260
|
end
|
|
261
|
-
|
|
261
|
+
|
|
262
|
+
result = aggregator.to_h
|
|
263
|
+
# A complete Converse stream always emits stopReason in its messageStop
|
|
264
|
+
# frame. Its absence means the upstream cut the stream mid-response,
|
|
265
|
+
# leaving a half-written message; retry rather than accept the truncation.
|
|
266
|
+
if result["stopReason"].nil?
|
|
267
|
+
raise Clacky::UpstreamTruncatedError,
|
|
268
|
+
"[LLM] Streaming response ended without stopReason (upstream cut the stream). Retrying..."
|
|
269
|
+
end
|
|
270
|
+
MessageFormat::Bedrock.parse_response(result)
|
|
262
271
|
end
|
|
263
272
|
|
|
264
273
|
def parse_simple_bedrock_response(response)
|
|
@@ -307,7 +316,16 @@ module Clacky
|
|
|
307
316
|
recovered = Struct.new(:status, :body).new(response.status, recovered_body)
|
|
308
317
|
raise_error(recovered)
|
|
309
318
|
end
|
|
310
|
-
|
|
319
|
+
|
|
320
|
+
result = aggregator.to_h
|
|
321
|
+
# A complete Messages stream always emits stop_reason in its message_delta
|
|
322
|
+
# frame. Its absence means the upstream cut the stream mid-response,
|
|
323
|
+
# leaving a half-written message; retry rather than accept the truncation.
|
|
324
|
+
if result["stop_reason"].nil?
|
|
325
|
+
raise Clacky::UpstreamTruncatedError,
|
|
326
|
+
"[LLM] Streaming response ended without stop_reason (upstream cut the stream). Retrying..."
|
|
327
|
+
end
|
|
328
|
+
MessageFormat::Anthropic.parse_response(result)
|
|
311
329
|
end
|
|
312
330
|
|
|
313
331
|
def parse_simple_anthropic_response(response)
|
|
@@ -360,7 +378,18 @@ module Clacky
|
|
|
360
378
|
response.env.body = sse_buf if response.body.to_s.empty?
|
|
361
379
|
raise_error(response)
|
|
362
380
|
end
|
|
363
|
-
|
|
381
|
+
|
|
382
|
+
result = aggregator.to_h
|
|
383
|
+
# A complete chat-completion stream always terminates with a frame
|
|
384
|
+
# carrying finish_reason. Its absence means the upstream cut the stream
|
|
385
|
+
# mid-response (e.g. proxy idle-timeout, connection reset that Faraday
|
|
386
|
+
# didn't surface as an exception), leaving a half-written message. Treat
|
|
387
|
+
# as retryable so we don't hand a silently truncated answer to the agent.
|
|
388
|
+
if result.dig("choices", 0, "finish_reason").nil?
|
|
389
|
+
raise Clacky::UpstreamTruncatedError,
|
|
390
|
+
"[LLM] Streaming response ended without finish_reason (upstream cut the stream). Retrying..."
|
|
391
|
+
end
|
|
392
|
+
MessageFormat::OpenAI.parse_response(result)
|
|
364
393
|
end
|
|
365
394
|
|
|
366
395
|
def parse_simple_openai_response(response)
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: deploy
|
|
3
3
|
description: Deploy Rails applications to Railway. Handles first-time setup and re-deploys idempotently using Railway CLI. Trigger on: "deploy", "deploy to railway", "railway deploy", "发布", "部署", "上线".
|
|
4
|
-
|
|
4
|
+
agent: coding
|
|
5
|
+
disable-model-invocation: false
|
|
5
6
|
---
|
|
6
7
|
|
|
7
8
|
# Deploy Rails App to Railway
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: extend-openclacky
|
|
3
|
+
description: Customize, fix, override or extend openclacky itself — e.g. change a built-in tool's behavior, intercept/audit/block tool calls with shell scripts, or plug in a new IM channel (Slack, in-house IM, etc.). Trigger on phrases like "patch clacky", "patch openclacky", "change WebSearch behavior", "block dangerous commands", "audit tool use", "add Slack channel", "改 openclacky 内置", "改 clacky 内置", "monkey patch openclacky", "拦截工具调用". Do NOT trigger for ordinary feature work in the user's own project that doesn't touch openclacky.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Extending Openclacky
|
|
7
|
+
|
|
8
|
+
Openclacky ships three official extension mechanisms that survive `gem update` and never require editing the gem source.
|
|
9
|
+
**Never tell the user to `bundle show openclacky` and edit the gem — always use one of these.**
|
|
10
|
+
|
|
11
|
+
## Pick the right mechanism
|
|
12
|
+
|
|
13
|
+
| User wants to… | Use | Scaffold | Verify |
|
|
14
|
+
|---|---|---|---|
|
|
15
|
+
| Change behavior of an **existing method** in openclacky (e.g. `WebSearch#execute` timeout, fix a bug in a built-in tool) | **Patch** | `clacky patch_new <id> "Const#method" -d "<desc>"` | `clacky patch_verify` |
|
|
16
|
+
| **Audit / block / observe** tool calls (block `rm -rf /`, log every shell command) — no Ruby needed | **Shell Hook** | `clacky hook_new <id> -e <event>` | `clacky hook_verify` |
|
|
17
|
+
| Plug openclacky into a **new IM platform** (Slack, in-house IM, custom webhook…) | **Channel Adapter** | `clacky channel_new <platform_id>` | `clacky channel_verify` |
|
|
18
|
+
|
|
19
|
+
## Authoritative documentation
|
|
20
|
+
|
|
21
|
+
Each mechanism has a full reference doc — read the relevant one with `web_fetch` before writing code:
|
|
22
|
+
|
|
23
|
+
- Patches → https://www.openclacky.com/docs/extend-patches
|
|
24
|
+
- Shell Hooks → https://www.openclacky.com/docs/extend-shell-hooks
|
|
25
|
+
- Channel Adapters → https://www.openclacky.com/docs/extend-channel-adapter
|
|
26
|
+
|
|
27
|
+
## Execution playbook
|
|
28
|
+
|
|
29
|
+
1. **Identify** which mechanism fits (use the table above; ask if genuinely ambiguous).
|
|
30
|
+
2. **Read the doc** for that mechanism with `web_fetch`. Don't guess fields, hook events, or required methods — the doc is the contract.
|
|
31
|
+
3. **Run the scaffold** CLI command. It generates the file(s) in `~/.clacky/...` with correct meta.
|
|
32
|
+
4. **Edit** the generated file to implement the user's intent. Keep generated meta fields (`target`, `event`, `platform_id`, the `Clacky::ChannelRegistry.register(...)` line, etc.) intact unless the doc says otherwise.
|
|
33
|
+
5. **Verify** with the matching `*_verify` command. Surface any `[FAIL]` lines to the user verbatim.
|
|
34
|
+
|
|
35
|
+
## When NOT to use this skill
|
|
36
|
+
|
|
37
|
+
- The user is building features in their own application that just *use* openclacky — that's normal coding, no patch/hook/channel needed.
|
|
38
|
+
- The user wants a brand-new tool/skill for *their* project — use `.clacky/skills/` or `.clacky/tools/`, not these gem-level mechanisms.
|
|
39
|
+
- The change can be made via `clacky config set ...` — prefer config over patches.
|
|
@@ -5,13 +5,6 @@ description: |
|
|
|
5
5
|
reconfigure. Edits ~/.clacky/mcp.json so the user never writes JSON by hand.
|
|
6
6
|
Trigger on: add mcp, install mcp, setup mcp, configure mcp, mcp list, mcp remove,
|
|
7
7
|
mcp probe, mcp reconfigure.
|
|
8
|
-
argument-hint: "add | list | probe <name> | remove <name> | reconfigure <name>"
|
|
9
|
-
allowed-tools:
|
|
10
|
-
- Bash
|
|
11
|
-
- Read
|
|
12
|
-
- Write
|
|
13
|
-
- Edit
|
|
14
|
-
- AskFollowupQuestion
|
|
15
8
|
---
|
|
16
9
|
|
|
17
10
|
# MCP Manager Skill
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
begin
|
|
8
|
+
require "prism"
|
|
9
|
+
rescue LoadError
|
|
10
|
+
# Prism is a stdlib on Ruby 3.3+. On older Rubies we fall back to
|
|
11
|
+
# RubyVM::AbstractSyntaxTree (available since 2.6).
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module Clacky
|
|
15
|
+
# Runtime patch layer. Loads user/AI-authored patches from ~/.clacky/patches/
|
|
16
|
+
# that override existing methods via Module#prepend, WITHOUT touching the
|
|
17
|
+
# installed gem source (so `gem update` never loses them).
|
|
18
|
+
#
|
|
19
|
+
# Each patch lives in its own directory:
|
|
20
|
+
# ~/.clacky/patches/<id>/
|
|
21
|
+
# meta.yml declares target + a fingerprint of the original method source
|
|
22
|
+
# patch.rb a prepend module that overrides the target method
|
|
23
|
+
#
|
|
24
|
+
# Safety — fingerprint drift:
|
|
25
|
+
# meta.yml records a SHA256 of the targeted method's source at authoring time.
|
|
26
|
+
# Before applying, the loader recomputes the fingerprint of the method as it
|
|
27
|
+
# exists in the CURRENTLY installed gem. If they differ, the upstream code has
|
|
28
|
+
# changed and the patch may no longer be valid, so by default the patch is
|
|
29
|
+
# DISABLED (moved to _disabled/) rather than applied — a stale patch must never
|
|
30
|
+
# silently corrupt behavior.
|
|
31
|
+
#
|
|
32
|
+
# meta.yml:
|
|
33
|
+
# id: fix-web-search-timeout
|
|
34
|
+
# description: bump default timeout to 30s
|
|
35
|
+
# target: "Clacky::Tools::WebSearch#execute" # '#' = instance, '.' = class method
|
|
36
|
+
# fingerprint: "a3f8c…"
|
|
37
|
+
# gem_version: "0.7.0"
|
|
38
|
+
# on_mismatch: disable # disable | warn (default disable)
|
|
39
|
+
module PatchLoader
|
|
40
|
+
DEFAULT_DIR = File.expand_path("~/.clacky/patches")
|
|
41
|
+
DISABLED_DIR = "_disabled"
|
|
42
|
+
|
|
43
|
+
Result = Struct.new(:applied, :disabled, :skipped, keyword_init: true)
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
def load_all(dir: DEFAULT_DIR)
|
|
47
|
+
result = Result.new(applied: [], disabled: [], skipped: [])
|
|
48
|
+
if Dir.exist?(dir)
|
|
49
|
+
Dir.glob(File.join(dir, "*", "meta.yml")).sort.each do |meta_path|
|
|
50
|
+
patch_dir = File.dirname(meta_path)
|
|
51
|
+
next if File.basename(File.dirname(patch_dir)) == DISABLED_DIR
|
|
52
|
+
|
|
53
|
+
apply_one(patch_dir, meta_path, result)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
@last_result = result
|
|
57
|
+
result
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def last_result
|
|
61
|
+
@last_result || load_all
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Generate a ready-to-edit patch (meta.yml + patch.rb) for a target method.
|
|
65
|
+
# Computes the current fingerprint automatically so the author never does it
|
|
66
|
+
# by hand. The patch.rb skeleton prepends a module that overrides the method
|
|
67
|
+
# and calls super by default.
|
|
68
|
+
# @param target [String] "Const::Path#method" or "Const::Path.method"
|
|
69
|
+
# @return [String] path to the new patch directory
|
|
70
|
+
def scaffold(id, target, description: "", dir: DEFAULT_DIR)
|
|
71
|
+
slug = id.to_s.strip.downcase.gsub(/[^a-z0-9_-]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
72
|
+
raise ArgumentError, "invalid patch id: #{id.inspect}" if slug.empty?
|
|
73
|
+
|
|
74
|
+
fp = fingerprint(target) # also validates the target resolves
|
|
75
|
+
|
|
76
|
+
patch_dir = File.join(dir, slug)
|
|
77
|
+
raise ArgumentError, "patch already exists: #{patch_dir}" if Dir.exist?(patch_dir)
|
|
78
|
+
|
|
79
|
+
FileUtils.mkdir_p(patch_dir)
|
|
80
|
+
File.write(File.join(patch_dir, "meta.yml"), <<~YAML)
|
|
81
|
+
id: #{slug}
|
|
82
|
+
description: #{description.to_s.empty? ? "(describe what this fixes)" : description}
|
|
83
|
+
target: "#{target}"
|
|
84
|
+
fingerprint: "#{fp}"
|
|
85
|
+
gem_version: "#{Clacky::VERSION}"
|
|
86
|
+
on_mismatch: disable
|
|
87
|
+
YAML
|
|
88
|
+
File.write(File.join(patch_dir, "patch.rb"), patch_skeleton(slug, target))
|
|
89
|
+
patch_dir
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def patch_skeleton(slug, target)
|
|
93
|
+
const_name, sep, method_name = target.partition(/[#.]/)
|
|
94
|
+
mod_const = "Patch_#{slug.gsub(/[^a-zA-Z0-9_]/, "_")}"
|
|
95
|
+
prepend_target = sep == "#" ? const_name : "#{const_name}.singleton_class"
|
|
96
|
+
|
|
97
|
+
<<~RUBY
|
|
98
|
+
# frozen_string_literal: true
|
|
99
|
+
|
|
100
|
+
# Patch for #{target}
|
|
101
|
+
# Only edit the method body below. Call `super` to keep the original behavior.
|
|
102
|
+
module #{mod_const}
|
|
103
|
+
def #{method_name}(*args, **kwargs, &blk)
|
|
104
|
+
# TODO: your fix here. Examples:
|
|
105
|
+
# result = super
|
|
106
|
+
# result
|
|
107
|
+
super
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
#{prepend_target}.prepend(#{mod_const})
|
|
112
|
+
RUBY
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Recompute the fingerprint of a target's method as currently installed.
|
|
116
|
+
# @param target [String] "Const::Path#instance_method" or "Const::Path.class_method"
|
|
117
|
+
# @return [String] SHA256 hex of the method's source
|
|
118
|
+
# @raise [RuntimeError] if the target can't be resolved
|
|
119
|
+
def fingerprint(target)
|
|
120
|
+
meth = original_method(resolve_method(target))
|
|
121
|
+
file, lineno = meth.source_location
|
|
122
|
+
raise "no source location for #{target} (defined in C or eval?)" unless file && lineno
|
|
123
|
+
|
|
124
|
+
first, last = method_line_range(file, lineno, meth.name, meth)
|
|
125
|
+
raise "cannot locate source for #{target} in #{file}:#{lineno}" unless first && last
|
|
126
|
+
|
|
127
|
+
lines = File.readlines(file)[(first - 1)...last]
|
|
128
|
+
Digest::SHA256.hexdigest(lines.join)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def method_line_range(file, lineno, name, meth)
|
|
132
|
+
if defined?(Prism)
|
|
133
|
+
range = prism_line_range(file, lineno, name)
|
|
134
|
+
return range if range
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
ast_line_range(meth)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def prism_line_range(file, lineno, name)
|
|
141
|
+
result = Prism.parse_file(file)
|
|
142
|
+
return nil unless result.success?
|
|
143
|
+
|
|
144
|
+
node = find_def_at(result.value, lineno, name.to_sym)
|
|
145
|
+
return nil unless node
|
|
146
|
+
|
|
147
|
+
loc = node.location
|
|
148
|
+
[loc.start_line, loc.end_line]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def find_def_at(node, lineno, name)
|
|
152
|
+
return nil unless node
|
|
153
|
+
|
|
154
|
+
if node.is_a?(Prism::DefNode) && node.name == name && node.location.start_line == lineno
|
|
155
|
+
return node
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
node.compact_child_nodes.each do |child|
|
|
159
|
+
found = find_def_at(child, lineno, name)
|
|
160
|
+
return found if found
|
|
161
|
+
end
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def ast_line_range(meth)
|
|
166
|
+
return nil unless defined?(RubyVM::AbstractSyntaxTree)
|
|
167
|
+
|
|
168
|
+
node = RubyVM::AbstractSyntaxTree.of(meth)
|
|
169
|
+
return nil unless node
|
|
170
|
+
|
|
171
|
+
[node.first_lineno, node.last_lineno]
|
|
172
|
+
rescue StandardError
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Walk past any methods introduced by our own patches (files under the
|
|
177
|
+
# patches dir) so the fingerprint always reflects the original upstream
|
|
178
|
+
# definition, even after a prepend has already been applied.
|
|
179
|
+
def original_method(meth)
|
|
180
|
+
current = meth
|
|
181
|
+
while current
|
|
182
|
+
file, = current.source_location
|
|
183
|
+
break if file.nil? || !file.start_with?(DEFAULT_DIR)
|
|
184
|
+
|
|
185
|
+
nxt = current.super_method
|
|
186
|
+
break if nxt.nil?
|
|
187
|
+
|
|
188
|
+
current = nxt
|
|
189
|
+
end
|
|
190
|
+
current
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def resolve_method(target)
|
|
194
|
+
if target.include?("#")
|
|
195
|
+
const_name, method_name = target.split("#", 2)
|
|
196
|
+
const = resolve_const(const_name)
|
|
197
|
+
const.instance_method(method_name.to_sym)
|
|
198
|
+
elsif target.include?(".")
|
|
199
|
+
const_name, method_name = target.split(".", 2)
|
|
200
|
+
const = resolve_const(const_name)
|
|
201
|
+
const.method(method_name.to_sym)
|
|
202
|
+
else
|
|
203
|
+
raise "invalid target (need '#' or '.'): #{target}"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def apply_one(patch_dir, meta_path, result)
|
|
208
|
+
id = File.basename(patch_dir)
|
|
209
|
+
meta = YAMLCompat.load_file(meta_path) || {}
|
|
210
|
+
target = meta["target"].to_s
|
|
211
|
+
recorded = meta["fingerprint"].to_s
|
|
212
|
+
|
|
213
|
+
if target.empty? || recorded.empty?
|
|
214
|
+
result.skipped << [id, "meta.yml missing target or fingerprint"]
|
|
215
|
+
log(:warn, id, result.skipped.last[1])
|
|
216
|
+
return
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
current = begin
|
|
220
|
+
fingerprint(target)
|
|
221
|
+
rescue StandardError => e
|
|
222
|
+
result.skipped << [id, "cannot fingerprint #{target}: #{e.message}"]
|
|
223
|
+
log(:warn, id, result.skipped.last[1])
|
|
224
|
+
return
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
if current != recorded
|
|
228
|
+
handle_mismatch(patch_dir, id, meta, result)
|
|
229
|
+
return
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
patch_rb = File.join(patch_dir, "patch.rb")
|
|
233
|
+
unless File.exist?(patch_rb)
|
|
234
|
+
result.skipped << [id, "patch.rb not found"]
|
|
235
|
+
log(:warn, id, result.skipped.last[1])
|
|
236
|
+
return
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
require patch_rb
|
|
240
|
+
result.applied << id
|
|
241
|
+
log(:info, id, "applied → #{target}")
|
|
242
|
+
rescue StandardError, ScriptError => e
|
|
243
|
+
result.skipped << [id, e.message]
|
|
244
|
+
log(:warn, id, e.message)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def handle_mismatch(patch_dir, id, meta, result)
|
|
248
|
+
reason = "fingerprint mismatch — upstream code for #{meta["target"]} changed"
|
|
249
|
+
if meta["on_mismatch"].to_s == "warn"
|
|
250
|
+
result.skipped << [id, "#{reason} (kept, not applied)"]
|
|
251
|
+
log(:warn, id, result.skipped.last[1])
|
|
252
|
+
return
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
disable!(patch_dir, id)
|
|
256
|
+
result.disabled << [id, reason]
|
|
257
|
+
log(:warn, id, "#{reason} — disabled")
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def disable!(patch_dir, id)
|
|
261
|
+
base = File.dirname(patch_dir)
|
|
262
|
+
dest_root = File.join(base, DISABLED_DIR)
|
|
263
|
+
FileUtils.mkdir_p(dest_root)
|
|
264
|
+
dest = File.join(dest_root, id)
|
|
265
|
+
FileUtils.rm_rf(dest)
|
|
266
|
+
FileUtils.mv(patch_dir, dest)
|
|
267
|
+
rescue StandardError => e
|
|
268
|
+
log(:error, id, "failed to disable: #{e.message}")
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def resolve_const(name)
|
|
272
|
+
name.split("::").reject(&:empty?).inject(Object) do |mod, part|
|
|
273
|
+
mod.const_get(part)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def log(level, id, msg)
|
|
278
|
+
Clacky::Logger.public_send(level, "[PatchLoader] #{id}: #{msg}")
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|