shared_tools 0.4.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/README.md +211 -7
  4. data/lib/shared_tools/apply_patch_tool.rb +4 -0
  5. data/lib/shared_tools/bash_tool.rb +4 -0
  6. data/lib/shared_tools/bundle_tool.rb +4 -0
  7. data/lib/shared_tools/command_guard.rb +35 -0
  8. data/lib/shared_tools/csv_read_tool.rb +4 -0
  9. data/lib/shared_tools/csv_write_tool.rb +4 -0
  10. data/lib/shared_tools/data_path.rb +52 -0
  11. data/lib/shared_tools/diff_tool.rb +4 -0
  12. data/lib/shared_tools/download_file_tool.rb +4 -0
  13. data/lib/shared_tools/gem_tool.rb +4 -0
  14. data/lib/shared_tools/git_tool.rb +5 -0
  15. data/lib/shared_tools/glob_tool.rb +4 -0
  16. data/lib/shared_tools/http_request_tool.rb +4 -0
  17. data/lib/shared_tools/json_query_tool.rb +4 -0
  18. data/lib/shared_tools/lint_tool.rb +4 -0
  19. data/lib/shared_tools/mcp/brave_search_client.rb +18 -16
  20. data/lib/shared_tools/mcp/chart_client.rb +18 -15
  21. data/lib/shared_tools/mcp/client_support.rb +103 -0
  22. data/lib/shared_tools/mcp/github_client.rb +23 -19
  23. data/lib/shared_tools/mcp/hugging_face_client.rb +22 -22
  24. data/lib/shared_tools/mcp/memory_client.rb +20 -15
  25. data/lib/shared_tools/mcp/notion_client.rb +21 -19
  26. data/lib/shared_tools/mcp/playwright_client.rb +19 -15
  27. data/lib/shared_tools/mcp/sequential_thinking_client.rb +17 -14
  28. data/lib/shared_tools/mcp/slack_client.rb +30 -25
  29. data/lib/shared_tools/mcp/tavily_client.rb +21 -21
  30. data/lib/shared_tools/mcp.rb +32 -41
  31. data/lib/shared_tools/multi_edit_tool.rb +4 -0
  32. data/lib/shared_tools/parse_ruby_tool.rb +4 -0
  33. data/lib/shared_tools/process_kill_tool.rb +4 -0
  34. data/lib/shared_tools/process_list_tool.rb +4 -0
  35. data/lib/shared_tools/process_output_tool.rb +4 -0
  36. data/lib/shared_tools/process_registry.rb +224 -0
  37. data/lib/shared_tools/process_runner.rb +69 -0
  38. data/lib/shared_tools/process_start_tool.rb +4 -0
  39. data/lib/shared_tools/python_tests_tool.rb +4 -0
  40. data/lib/shared_tools/replace_in_files_tool.rb +4 -0
  41. data/lib/shared_tools/ruby_outline.rb +207 -0
  42. data/lib/shared_tools/run_tests_tool.rb +4 -0
  43. data/lib/shared_tools/text_diff.rb +80 -0
  44. data/lib/shared_tools/todo_write_tool.rb +4 -0
  45. data/lib/shared_tools/toml_parser.rb +409 -0
  46. data/lib/shared_tools/toml_query_tool.rb +4 -0
  47. data/lib/shared_tools/tools/apply_patch_tool.rb +122 -0
  48. data/lib/shared_tools/tools/bash_tool.rb +102 -0
  49. data/lib/shared_tools/tools/browser/click_tool.rb +1 -1
  50. data/lib/shared_tools/tools/browser/inspect_tool.rb +1 -1
  51. data/lib/shared_tools/tools/browser/page_inspect_tool.rb +1 -1
  52. data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +1 -1
  53. data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +1 -1
  54. data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +1 -1
  55. data/lib/shared_tools/tools/browser/visit_tool.rb +1 -1
  56. data/lib/shared_tools/tools/browser_tool.rb +1 -1
  57. data/lib/shared_tools/tools/bundle_tool.rb +96 -0
  58. data/lib/shared_tools/tools/calculator_tool.rb +1 -1
  59. data/lib/shared_tools/tools/clipboard_tool.rb +1 -1
  60. data/lib/shared_tools/tools/composite_analysis_tool.rb +1 -1
  61. data/lib/shared_tools/tools/computer_tool.rb +1 -1
  62. data/lib/shared_tools/tools/cron_tool.rb +1 -1
  63. data/lib/shared_tools/tools/csv_read_tool.rb +82 -0
  64. data/lib/shared_tools/tools/csv_write_tool.rb +67 -0
  65. data/lib/shared_tools/tools/current_date_time_tool.rb +1 -1
  66. data/lib/shared_tools/tools/data_science_kit.rb +1 -1
  67. data/lib/shared_tools/tools/database_query_tool.rb +1 -1
  68. data/lib/shared_tools/tools/database_tool.rb +1 -1
  69. data/lib/shared_tools/tools/diff_tool.rb +48 -0
  70. data/lib/shared_tools/tools/disk/directory_create_tool.rb +1 -1
  71. data/lib/shared_tools/tools/disk/directory_delete_tool.rb +1 -1
  72. data/lib/shared_tools/tools/disk/directory_list_tool.rb +1 -1
  73. data/lib/shared_tools/tools/disk/directory_move_tool.rb +1 -1
  74. data/lib/shared_tools/tools/disk/file_create_tool.rb +1 -1
  75. data/lib/shared_tools/tools/disk/file_delete_tool.rb +1 -1
  76. data/lib/shared_tools/tools/disk/file_move_tool.rb +1 -1
  77. data/lib/shared_tools/tools/disk/file_read_tool.rb +1 -1
  78. data/lib/shared_tools/tools/disk/file_replace_tool.rb +1 -1
  79. data/lib/shared_tools/tools/disk/file_write_tool.rb +1 -1
  80. data/lib/shared_tools/tools/disk_tool.rb +1 -1
  81. data/lib/shared_tools/tools/dns_tool.rb +1 -1
  82. data/lib/shared_tools/tools/doc/docx_reader_tool.rb +1 -1
  83. data/lib/shared_tools/tools/doc/pdf_reader_tool.rb +1 -1
  84. data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +1 -1
  85. data/lib/shared_tools/tools/doc/text_reader_tool.rb +1 -1
  86. data/lib/shared_tools/tools/doc_tool.rb +1 -1
  87. data/lib/shared_tools/tools/docker/compose_run_tool.rb +1 -1
  88. data/lib/shared_tools/tools/download_file_tool.rb +87 -0
  89. data/lib/shared_tools/tools/enabler.rb +2 -2
  90. data/lib/shared_tools/tools/error_handling_tool.rb +1 -1
  91. data/lib/shared_tools/tools/eval/python_eval_tool.rb +1 -1
  92. data/lib/shared_tools/tools/eval/ruby_eval_tool.rb +1 -1
  93. data/lib/shared_tools/tools/eval/shell_eval_tool.rb +1 -1
  94. data/lib/shared_tools/tools/eval_tool.rb +1 -1
  95. data/lib/shared_tools/tools/gem_tool.rb +157 -0
  96. data/lib/shared_tools/tools/git/add_tool.rb +73 -0
  97. data/lib/shared_tools/tools/git/blame_tool.rb +77 -0
  98. data/lib/shared_tools/tools/git/branch_tool.rb +55 -0
  99. data/lib/shared_tools/tools/git/checkout_tool.rb +69 -0
  100. data/lib/shared_tools/tools/git/commit_tool.rb +73 -0
  101. data/lib/shared_tools/tools/git/diff_tool.rb +69 -0
  102. data/lib/shared_tools/tools/git/grep_tool.rb +82 -0
  103. data/lib/shared_tools/tools/git/helpers.rb +70 -0
  104. data/lib/shared_tools/tools/git/log_tool.rb +71 -0
  105. data/lib/shared_tools/tools/git/show_tool.rb +66 -0
  106. data/lib/shared_tools/tools/git/status_tool.rb +46 -0
  107. data/lib/shared_tools/tools/git.rb +18 -0
  108. data/lib/shared_tools/tools/git_tool.rb +148 -0
  109. data/lib/shared_tools/tools/glob_tool.rb +75 -0
  110. data/lib/shared_tools/tools/http_helpers.rb +119 -0
  111. data/lib/shared_tools/tools/http_request_tool.rb +91 -0
  112. data/lib/shared_tools/tools/json_query_tool.rb +81 -0
  113. data/lib/shared_tools/tools/lint_tool.rb +93 -0
  114. data/lib/shared_tools/tools/multi_edit_tool.rb +134 -0
  115. data/lib/shared_tools/tools/notification_tool.rb +1 -1
  116. data/lib/shared_tools/tools/parse_ruby_tool.rb +127 -0
  117. data/lib/shared_tools/tools/process_kill_tool.rb +55 -0
  118. data/lib/shared_tools/tools/process_list_tool.rb +41 -0
  119. data/lib/shared_tools/tools/process_output_tool.rb +69 -0
  120. data/lib/shared_tools/tools/process_start_tool.rb +0 -0
  121. data/lib/shared_tools/tools/python_tests_tool.rb +105 -0
  122. data/lib/shared_tools/tools/replace_in_files_tool.rb +166 -0
  123. data/lib/shared_tools/tools/run_tests_tool.rb +108 -0
  124. data/lib/shared_tools/tools/search_codebase_tool.rb +178 -47
  125. data/lib/shared_tools/tools/secure_tool_template.rb +1 -1
  126. data/lib/shared_tools/tools/system_info_tool.rb +1 -1
  127. data/lib/shared_tools/tools/todo_write_tool.rb +81 -0
  128. data/lib/shared_tools/tools/toml_query_tool.rb +81 -0
  129. data/lib/shared_tools/tools/toolchain_helpers.rb +64 -0
  130. data/lib/shared_tools/tools/tree_tool.rb +100 -0
  131. data/lib/shared_tools/tools/weather_tool.rb +1 -1
  132. data/lib/shared_tools/tools/web_fetch_tool.rb +85 -0
  133. data/lib/shared_tools/tools/workflow_manager_tool.rb +1 -1
  134. data/lib/shared_tools/tools/yaml_query_tool.rb +86 -0
  135. data/lib/shared_tools/tree_tool.rb +4 -0
  136. data/lib/shared_tools/url_guard.rb +106 -0
  137. data/lib/shared_tools/utilities.rb +2 -2
  138. data/lib/shared_tools/version.rb +1 -1
  139. data/lib/shared_tools/web_fetch_tool.rb +4 -0
  140. data/lib/shared_tools/yaml_query_tool.rb +4 -0
  141. data/lib/shared_tools.rb +1 -1
  142. metadata +516 -409
  143. data/lib/shared_tools/mcp/streamable_http_patch.rb +0 -42
  144. data/lib/shared_tools/tools/version.rb +0 -7
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharedTools
4
+ # Line-based diff via longest-common-subsequence, used by DiffTool. Produces
5
+ # a readable diff ('-'/'+'/' ' prefixes) with long unchanged runs elided.
6
+ # Pure stdlib, no external dependency.
7
+ module TextDiff
8
+ module_function
9
+
10
+ def unified(old_text, new_text, old_label: "old", new_label: "new", context: 3)
11
+ a = old_text.to_s.lines
12
+ b = new_text.to_s.lines
13
+ ops = diff_ops(a, b)
14
+ return "(no differences)" if ops.all? { |type, _| type == :eq }
15
+
16
+ render(ops, old_label, new_label, context)
17
+ end
18
+
19
+ def diff_ops(a, b)
20
+ n = a.size
21
+ m = b.size
22
+ lcs = Array.new(n + 1) { Array.new(m + 1, 0) }
23
+ (n - 1).downto(0) do |i|
24
+ (m - 1).downto(0) do |j|
25
+ lcs[i][j] = a[i] == b[j] ? lcs[i + 1][j + 1] + 1 : [lcs[i + 1][j], lcs[i][j + 1]].max
26
+ end
27
+ end
28
+
29
+ ops = []
30
+ i = 0
31
+ j = 0
32
+ while i < n && j < m
33
+ if a[i] == b[j]
34
+ ops << [:eq, a[i]]
35
+ i += 1
36
+ j += 1
37
+ elsif lcs[i + 1][j] >= lcs[i][j + 1]
38
+ ops << [:del, a[i]]
39
+ i += 1
40
+ else
41
+ ops << [:add, b[j]]
42
+ j += 1
43
+ end
44
+ end
45
+ ops.concat(a[i..].map { |line| [:del, line] }) if i < n
46
+ ops.concat(b[j..].map { |line| [:add, line] }) if j < m
47
+ ops
48
+ end
49
+
50
+ def render(ops, old_label, new_label, context)
51
+ out = ["--- #{old_label}", "+++ #{new_label}"]
52
+ i = 0
53
+ while i < ops.size
54
+ type, line = ops[i]
55
+ if type == :eq
56
+ run = []
57
+ while i < ops.size && ops[i][0] == :eq
58
+ run << ops[i][1]
59
+ i += 1
60
+ end
61
+ emit_context(out, run, context)
62
+ else
63
+ out << "#{type == :del ? '-' : '+'} #{line.chomp}"
64
+ i += 1
65
+ end
66
+ end
67
+ out.join("\n")
68
+ end
69
+
70
+ def emit_context(out, run, context)
71
+ if run.size > (context * 2) + 1
72
+ run.first(context).each { |line| out << " #{line.chomp}" }
73
+ out << " ⋮ (#{run.size - (context * 2)} unchanged lines)"
74
+ run.last(context).each { |line| out << " #{line.chomp}" }
75
+ else
76
+ run.each { |line| out << " #{line.chomp}" }
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ # Shim: require 'shared_tools/todo_write_tool'
3
+ require 'shared_tools'
4
+ require 'shared_tools/tools/todo_write_tool'
@@ -0,0 +1,409 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SharedTools
4
+ # A small, dependency-free TOML parser used by TomlQueryTool. Covers the
5
+ # common surface of TOML 1.0 used by real config files: comments;
6
+ # bare/quoted/dotted keys; [tables] and [[arrays of tables]]; basic/literal
7
+ # strings and their multiline forms; integers (with underscores and
8
+ # 0x/0o/0b), floats (exponents, inf, nan), booleans; offset/local
9
+ # date-times, dates and times (kept as strings); arrays (multiline, nested,
10
+ # heterogeneous); and inline tables. Returns nested Hashes/Arrays with
11
+ # string keys, ready for JSON output or DataPath navigation.
12
+ module TomlParser
13
+ class ParseError < StandardError; end
14
+
15
+ module_function
16
+
17
+ def parse(text)
18
+ Parser.new(text).parse
19
+ end
20
+
21
+ # Recursive-descent parser over a character cursor.
22
+ class Parser
23
+ DATETIME = /\A(\d{4}-\d{2}-\d{2}([Tt ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})?)?|\d{2}:\d{2}:\d{2}(\.\d+)?)/
24
+
25
+ def initialize(source)
26
+ @s = source.to_s
27
+ @i = 0
28
+ @len = @s.length
29
+ @root = {}
30
+ end
31
+
32
+ def parse
33
+ @current = @root
34
+ loop do
35
+ skip_blank
36
+ break if eof?
37
+
38
+ if peek == "["
39
+ parse_table_header
40
+ else
41
+ key = parse_key
42
+ skip_inline_ws
43
+ expect("=")
44
+ value = parse_value
45
+ assign(@current, key, value)
46
+ expect_line_end
47
+ end
48
+ end
49
+ @root
50
+ end
51
+
52
+ private
53
+
54
+ # --- table headers ------------------------------------------------
55
+ def parse_table_header
56
+ advance # [
57
+ array = false
58
+ if peek == "["
59
+ advance
60
+ array = true
61
+ end
62
+ skip_inline_ws
63
+ keys = parse_key
64
+ skip_inline_ws
65
+ expect("]")
66
+ expect("]") if array
67
+ expect_line_end
68
+
69
+ @current = array ? open_array_table(keys) : open_table(keys)
70
+ end
71
+
72
+ def open_table(keys)
73
+ node = @root
74
+ keys.each do |k|
75
+ node[k] = {} unless node.key?(k)
76
+ raise ParseError, "cannot redefine #{k.inspect} as a table" unless node[k].is_a?(Hash) || node[k].is_a?(Array)
77
+
78
+ node = node[k].is_a?(Array) ? node[k].last : node[k]
79
+ end
80
+ node
81
+ end
82
+
83
+ def open_array_table(keys)
84
+ node = @root
85
+ keys[0..-2].each do |k|
86
+ node[k] = {} unless node.key?(k)
87
+ raise ParseError, "cannot redefine #{k.inspect} as a table" unless node[k].is_a?(Hash) || node[k].is_a?(Array)
88
+
89
+ node = node[k].is_a?(Array) ? node[k].last : node[k]
90
+ end
91
+ last = keys[-1]
92
+ node[last] ||= []
93
+ raise ParseError, "key #{last.inspect} is not an array of tables" unless node[last].is_a?(Array)
94
+
95
+ fresh = {}
96
+ node[last] << fresh
97
+ fresh
98
+ end
99
+
100
+ # --- keys ---------------------------------------------------------
101
+ def parse_key
102
+ parts = []
103
+ loop do
104
+ skip_inline_ws
105
+ parts << parse_key_segment
106
+ skip_inline_ws
107
+ break unless peek == "."
108
+
109
+ advance
110
+ end
111
+ parts
112
+ end
113
+
114
+ def parse_key_segment
115
+ case peek
116
+ when '"' then parse_basic_string
117
+ when "'" then parse_literal_string
118
+ else
119
+ start = @i
120
+ advance while !eof? && peek.match?(/[A-Za-z0-9_-]/)
121
+ raise ParseError, "empty key near position #{@i}" if @i == start
122
+
123
+ @s[start...@i]
124
+ end
125
+ end
126
+
127
+ def assign(table, keys, value)
128
+ node = table
129
+ keys[0..-2].each do |k|
130
+ node[k] = {} unless node.key?(k)
131
+ raise ParseError, "cannot redefine #{k.inspect} as a table" unless node[k].is_a?(Hash)
132
+
133
+ node = node[k]
134
+ end
135
+ node[keys[-1]] = value
136
+ end
137
+
138
+ # --- values -------------------------------------------------------
139
+ def parse_value
140
+ skip_inline_ws
141
+ case peek
142
+ when '"', "'" then parse_string
143
+ when "[" then parse_array
144
+ when "{" then parse_inline_table
145
+ else parse_atom
146
+ end
147
+ end
148
+
149
+ def parse_atom
150
+ rest = @s[@i..]
151
+ if (m = rest.match(DATETIME))
152
+ @i += m[0].length
153
+ return m[0]
154
+ end
155
+ if rest.start_with?("true")
156
+ @i += 4
157
+ return true
158
+ end
159
+ if rest.start_with?("false")
160
+ @i += 5
161
+ return false
162
+ end
163
+ parse_number
164
+ end
165
+
166
+ def parse_number
167
+ start = @i
168
+ advance while !eof? && !peek.match?(/[\s,\]}#]/)
169
+ token = @s[start...@i]
170
+ raise ParseError, "expected a value near position #{start}" if token.empty?
171
+
172
+ classify_number(token)
173
+ end
174
+
175
+ def classify_number(token)
176
+ t = token.gsub("_", "")
177
+ return Float::INFINITY if %w[inf +inf].include?(t)
178
+ return -Float::INFINITY if t == "-inf"
179
+ return Float::NAN if %w[nan +nan -nan].include?(t)
180
+
181
+ if (m = t.match(/\A[+-]?0(x|o|b)(.+)\z/))
182
+ base = { "x" => 16, "o" => 8, "b" => 2 }[m[1]]
183
+ return Integer(m[2], base)
184
+ end
185
+ return Float(t) if t.match?(/[.eE]/) && !t.match?(/\A[+-]?0x/i)
186
+
187
+ Integer(t, 10)
188
+ rescue ArgumentError
189
+ raise ParseError, "invalid number: #{token.inspect}"
190
+ end
191
+
192
+ # --- strings ------------------------------------------------------
193
+ def parse_string
194
+ if @s[@i, 3] == '"""'
195
+ parse_multiline_basic
196
+ elsif @s[@i, 3] == "'''"
197
+ parse_multiline_literal
198
+ elsif peek == '"'
199
+ parse_basic_string
200
+ else
201
+ parse_literal_string
202
+ end
203
+ end
204
+
205
+ def parse_basic_string
206
+ advance # opening "
207
+ out = +""
208
+ until eof?
209
+ c = peek
210
+ raise ParseError, "unterminated string" if c == "\n"
211
+
212
+ if c == '"'
213
+ advance
214
+ return out
215
+ elsif c == "\\"
216
+ advance
217
+ out << read_escape
218
+ else
219
+ out << c
220
+ advance
221
+ end
222
+ end
223
+ raise ParseError, "unterminated string"
224
+ end
225
+
226
+ def parse_literal_string
227
+ advance # opening '
228
+ start = @i
229
+ advance while !eof? && peek != "'" && peek != "\n"
230
+ raise ParseError, "unterminated literal string" if eof? || peek == "\n"
231
+
232
+ str = @s[start...@i]
233
+ advance # closing '
234
+ str
235
+ end
236
+
237
+ def parse_multiline_basic
238
+ @i += 3
239
+ @i += 1 if peek == "\n"
240
+ out = +""
241
+ until eof?
242
+ if @s[@i, 3] == '"""'
243
+ @i += 3
244
+ return out
245
+ elsif peek == "\\" && @s[@i + 1..].match?(/\A[ \t]*\r?\n/)
246
+ @i += 1
247
+ @i += 1 while !eof? && peek.match?(/[\s]/)
248
+ elsif peek == "\\"
249
+ advance
250
+ out << read_escape
251
+ else
252
+ out << peek
253
+ advance
254
+ end
255
+ end
256
+ raise ParseError, "unterminated multiline string"
257
+ end
258
+
259
+ def parse_multiline_literal
260
+ @i += 3
261
+ @i += 1 if peek == "\n"
262
+ start = @i
263
+ until eof?
264
+ if @s[@i, 3] == "'''"
265
+ str = @s[start...@i]
266
+ @i += 3
267
+ return str
268
+ end
269
+ advance
270
+ end
271
+ raise ParseError, "unterminated multiline literal string"
272
+ end
273
+
274
+ def read_escape
275
+ c = peek
276
+ advance
277
+ case c
278
+ when "n" then "\n"
279
+ when "t" then "\t"
280
+ when "r" then "\r"
281
+ when "b" then "\b"
282
+ when "f" then "\f"
283
+ when '"' then '"'
284
+ when "\\" then "\\"
285
+ when "u" then read_unicode(4)
286
+ when "U" then read_unicode(8)
287
+ else raise ParseError, "invalid escape: \\#{c}"
288
+ end
289
+ end
290
+
291
+ def read_unicode(width)
292
+ hex = @s[@i, width]
293
+ raise ParseError, "invalid unicode escape" unless hex && hex.match?(/\A[0-9A-Fa-f]{#{width}}\z/)
294
+
295
+ @i += width
296
+ [hex.to_i(16)].pack("U")
297
+ end
298
+
299
+ # --- arrays & inline tables --------------------------------------
300
+ def parse_array
301
+ advance # [
302
+ arr = []
303
+ loop do
304
+ skip_ws_comments
305
+ break if eof?
306
+
307
+ if peek == "]"
308
+ advance
309
+ return arr
310
+ end
311
+ arr << parse_value
312
+ skip_ws_comments
313
+ if peek == ","
314
+ advance
315
+ elsif peek == "]"
316
+ advance
317
+ return arr
318
+ else
319
+ raise ParseError, "expected ',' or ']' in array near position #{@i}"
320
+ end
321
+ end
322
+ raise ParseError, "unterminated array"
323
+ end
324
+
325
+ def parse_inline_table
326
+ advance # {
327
+ table = {}
328
+ skip_ws_comments
329
+ if peek == "}"
330
+ advance
331
+ return table
332
+ end
333
+ loop do
334
+ skip_ws_comments
335
+ key = parse_key
336
+ skip_inline_ws
337
+ expect("=")
338
+ assign(table, key, parse_value)
339
+ skip_ws_comments
340
+ if peek == ","
341
+ advance
342
+ elsif peek == "}"
343
+ advance
344
+ return table
345
+ else
346
+ raise ParseError, "expected ',' or '}' in inline table near position #{@i}"
347
+ end
348
+ end
349
+ end
350
+
351
+ # --- cursor helpers ----------------------------------------------
352
+ def peek
353
+ @s[@i]
354
+ end
355
+
356
+ def advance
357
+ @i += 1
358
+ end
359
+
360
+ def eof?
361
+ @i >= @len
362
+ end
363
+
364
+ def expect(char)
365
+ raise ParseError, "expected #{char.inspect} near position #{@i}, got #{peek.inspect}" unless peek == char
366
+
367
+ advance
368
+ end
369
+
370
+ def skip_inline_ws
371
+ advance while !eof? && (peek == " " || peek == "\t")
372
+ end
373
+
374
+ # Between top-level statements: whitespace, newlines and comment lines.
375
+ def skip_blank
376
+ loop do
377
+ if !eof? && peek.match?(/[ \t\r\n]/)
378
+ advance
379
+ elsif peek == "#"
380
+ advance while !eof? && peek != "\n"
381
+ else
382
+ break
383
+ end
384
+ end
385
+ end
386
+
387
+ # Inside arrays/inline tables: whitespace, newlines and comments.
388
+ def skip_ws_comments
389
+ skip_blank
390
+ end
391
+
392
+ # After a key/value or header: optional comment, then newline or EOF.
393
+ def expect_line_end
394
+ skip_inline_ws
395
+ if peek == "#"
396
+ advance while !eof? && peek != "\n"
397
+ end
398
+ return if eof?
399
+
400
+ advance if peek == "\r"
401
+ if peek == "\n"
402
+ advance
403
+ elsif !eof?
404
+ raise ParseError, "expected end of line near position #{@i}, got #{peek.inspect}"
405
+ end
406
+ end
407
+ end
408
+ end
409
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ # Shim: require 'shared_tools/toml_query_tool'
3
+ require 'shared_tools'
4
+ require 'shared_tools/tools/toml_query_tool'
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'pathname'
5
+ require_relative '../../shared_tools'
6
+
7
+ module SharedTools
8
+ module Tools
9
+ # Applies a unified diff to files under root using `git apply`. Complements
10
+ # MultiEditTool when the caller wants to emit a whole multi-hunk patch
11
+ # (e.g. straight out of Git::DiffTool or DiffTool). The patch is validated
12
+ # with `git apply --check` before anything is written, and every touched
13
+ # path is confined to root — a patch cannot escape root even when root is
14
+ # a subdirectory of a larger git repo. Works with or without root being a
15
+ # git repository; requires git to be installed. Mutating — requires user
16
+ # authorization (see SharedTools.execute?).
17
+ #
18
+ # @example
19
+ # tool = SharedTools::Tools::ApplyPatchTool.new
20
+ # tool.execute(patch: unified_diff_text)
21
+ # tool.execute(patch: unified_diff_text, check: true) # dry run
22
+ class ApplyPatchTool < ::RubyLLM::Tool
23
+ GIT_ENV = { "GIT_PAGER" => "cat", "GIT_TERMINAL_PROMPT" => "0" }.freeze
24
+
25
+ def self.name = 'apply_patch'
26
+
27
+ description "Apply a unified diff to files under root (as produced by `git diff` / `diff -u`). " \
28
+ "Validates the patch first and reports the affected files; nothing is written if it " \
29
+ "would not apply cleanly. Set check: true for a dry run only. Requires git to be " \
30
+ "installed; root does not need to be a git repository."
31
+
32
+ parameters do
33
+ string :patch, description: "The unified diff text to apply."
34
+ boolean :check, description: "Validate only (dry run); do not write changes. Default false.", required: false
35
+ end
36
+
37
+ # @param root [String] optional, defaults to the current directory
38
+ # @param logger [Logger] optional logger
39
+ def initialize(root: nil, logger: nil)
40
+ @root = root || Dir.pwd
41
+ @logger = logger || RubyLLM.logger
42
+ end
43
+
44
+ # @param patch [String]
45
+ # @param check [Boolean]
46
+ #
47
+ # @return [String, Hash]
48
+ def execute(patch:, check: false)
49
+ @logger.info("#{self.class.name}#execute check=#{check}")
50
+
51
+ diff = patch.to_s
52
+ diff += "\n" unless diff.end_with?("\n")
53
+ return { error: "patch is empty" } if diff.strip.empty?
54
+
55
+ validate_patch_paths!(diff)
56
+
57
+ verify = run_git_apply("--check", stdin: diff)
58
+ return { error: "patch does not apply cleanly: #{message(verify)}" } unless succeeded?(verify)
59
+
60
+ files = changed_files(diff)
61
+ return "Patch applies cleanly (dry run). Affected files: #{files.join(', ')}" if check
62
+
63
+ allowed = SharedTools.execute?(tool: self.class.to_s, stuff: "Apply patch affecting #{files.size} file(s): #{files.join(', ')}")
64
+ unless allowed
65
+ @logger.warn("User declined to apply the patch")
66
+ return { error: "User declined to apply the patch" }
67
+ end
68
+
69
+ result = run_git_apply(stdin: diff)
70
+ return { error: "apply failed: #{message(result)}" } unless succeeded?(result)
71
+
72
+ "Applied patch to #{files.size} file#{files.size == 1 ? '' : 's'}: #{files.join(', ')}"
73
+ rescue SecurityError => e
74
+ @logger.error("#{self.class.name} path denied: #{e.message}")
75
+ { error: "patch path escapes root: #{e.message}" }
76
+ rescue Errno::ENOENT
77
+ @logger.error("#{self.class.name}: git is not available")
78
+ { error: "git is not available on the host" }
79
+ end
80
+
81
+ private
82
+
83
+ def run_git_apply(*args, stdin:)
84
+ Open3.capture3(GIT_ENV, "git", "-C", @root.to_s, "apply", *args, stdin_data: stdin)
85
+ end
86
+
87
+ def succeeded?((_out, _err, status))
88
+ status.exitstatus&.zero?
89
+ end
90
+
91
+ def message((out, err, _status))
92
+ (err.to_s.empty? ? out.to_s : err.to_s).strip
93
+ end
94
+
95
+ def validate_patch_paths!(diff)
96
+ root = Pathname.new(File.expand_path(@root))
97
+ diff.each_line do |line|
98
+ if line.start_with?("+++ ")
99
+ check_patch_path!(line[4..].chomp.sub(%r{\Ab/}, ""), root)
100
+ elsif line.start_with?("--- ")
101
+ check_patch_path!(line[4..].chomp.sub(%r{\Aa/}, ""), root)
102
+ end
103
+ end
104
+ end
105
+
106
+ def check_patch_path!(path, root)
107
+ return if path.empty? || path == "/dev/null"
108
+
109
+ resolved = (root + path).cleanpath
110
+ raise SecurityError, path unless resolved.ascend.any? { |ancestor| ancestor == root }
111
+ end
112
+
113
+ def changed_files(diff)
114
+ out, = run_git_apply("--numstat", stdin: diff)
115
+ out.to_s.each_line.filter_map do |line|
116
+ parts = line.strip.split("\t")
117
+ parts.last if parts.size >= 3
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end