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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8057fdabcb077c8ca378fcbbc0efa442abc6b9664464d2d8d1b737e0206d2ed9
4
- data.tar.gz: 657f282a20664ef793d7ff663e963739f5fbdb478b8e174df9c5e459ec09231b
3
+ metadata.gz: '080944ed788d584c01c97ba01a27a63b2d2ab341ecf88140a63424460dcb105e'
4
+ data.tar.gz: 3c2b51bde81be7c18b3384297609f0163d5e6ed40de7121185a4cc374e576f10
5
5
  SHA512:
6
- metadata.gz: fad3e045271032a1150745f1d8531aeed24ea376efdcd99346aeaeec4eb5f1edec187e9dbe38ee8d19e5a9cf599bfb7e5431ad55e5993f1dd243bb4ebf5faa2d
7
- data.tar.gz: 95b1a7ec783b459f5c70b99d3535f5a0054d4a8c72d855d9c98b9771cde9d59cb33dcf609be844fade932e1487c82c525cf4354438c5ad4b271494a4166dc729
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 daily cost breakdown for the last N days
119
- # @param days [Integer] Number of days to include
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
- MessageFormat::Bedrock.parse_response(aggregator.to_h)
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
- MessageFormat::Anthropic.parse_response(aggregator.to_h)
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
- MessageFormat::OpenAI.parse_response(aggregator.to_h)
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
- user-invocable: true
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