shared_tools 0.4.2 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/README.md +211 -7
- data/lib/shared_tools/apply_patch_tool.rb +4 -0
- data/lib/shared_tools/bash_tool.rb +4 -0
- data/lib/shared_tools/bundle_tool.rb +4 -0
- data/lib/shared_tools/command_guard.rb +35 -0
- data/lib/shared_tools/csv_read_tool.rb +4 -0
- data/lib/shared_tools/csv_write_tool.rb +4 -0
- data/lib/shared_tools/data_path.rb +52 -0
- data/lib/shared_tools/diff_tool.rb +4 -0
- data/lib/shared_tools/download_file_tool.rb +4 -0
- data/lib/shared_tools/gem_tool.rb +4 -0
- data/lib/shared_tools/git_tool.rb +5 -0
- data/lib/shared_tools/glob_tool.rb +4 -0
- data/lib/shared_tools/http_request_tool.rb +4 -0
- data/lib/shared_tools/json_query_tool.rb +4 -0
- data/lib/shared_tools/lint_tool.rb +4 -0
- data/lib/shared_tools/mcp/brave_search_client.rb +18 -16
- data/lib/shared_tools/mcp/chart_client.rb +18 -15
- data/lib/shared_tools/mcp/client_support.rb +103 -0
- data/lib/shared_tools/mcp/github_client.rb +23 -19
- data/lib/shared_tools/mcp/hugging_face_client.rb +22 -22
- data/lib/shared_tools/mcp/memory_client.rb +20 -15
- data/lib/shared_tools/mcp/notion_client.rb +21 -19
- data/lib/shared_tools/mcp/playwright_client.rb +19 -15
- data/lib/shared_tools/mcp/sequential_thinking_client.rb +17 -14
- data/lib/shared_tools/mcp/slack_client.rb +30 -25
- data/lib/shared_tools/mcp/tavily_client.rb +21 -21
- data/lib/shared_tools/mcp.rb +32 -41
- data/lib/shared_tools/multi_edit_tool.rb +4 -0
- data/lib/shared_tools/parse_ruby_tool.rb +4 -0
- data/lib/shared_tools/process_kill_tool.rb +4 -0
- data/lib/shared_tools/process_list_tool.rb +4 -0
- data/lib/shared_tools/process_output_tool.rb +4 -0
- data/lib/shared_tools/process_registry.rb +224 -0
- data/lib/shared_tools/process_runner.rb +69 -0
- data/lib/shared_tools/process_start_tool.rb +4 -0
- data/lib/shared_tools/python_tests_tool.rb +4 -0
- data/lib/shared_tools/replace_in_files_tool.rb +4 -0
- data/lib/shared_tools/ruby_outline.rb +207 -0
- data/lib/shared_tools/run_tests_tool.rb +4 -0
- data/lib/shared_tools/text_diff.rb +80 -0
- data/lib/shared_tools/todo_write_tool.rb +4 -0
- data/lib/shared_tools/toml_parser.rb +409 -0
- data/lib/shared_tools/toml_query_tool.rb +4 -0
- data/lib/shared_tools/tools/apply_patch_tool.rb +122 -0
- data/lib/shared_tools/tools/bash_tool.rb +102 -0
- data/lib/shared_tools/tools/browser/click_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/inspect_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/page_inspect_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/visit_tool.rb +1 -1
- data/lib/shared_tools/tools/browser_tool.rb +1 -1
- data/lib/shared_tools/tools/bundle_tool.rb +96 -0
- data/lib/shared_tools/tools/calculator_tool.rb +12 -2
- data/lib/shared_tools/tools/clipboard_tool.rb +1 -1
- data/lib/shared_tools/tools/composite_analysis_tool.rb +1 -1
- data/lib/shared_tools/tools/computer_tool.rb +1 -1
- data/lib/shared_tools/tools/cron_tool.rb +1 -1
- data/lib/shared_tools/tools/csv_read_tool.rb +82 -0
- data/lib/shared_tools/tools/csv_write_tool.rb +67 -0
- data/lib/shared_tools/tools/current_date_time_tool.rb +1 -1
- data/lib/shared_tools/tools/data_science_kit.rb +1 -1
- data/lib/shared_tools/tools/database_query_tool.rb +12 -2
- data/lib/shared_tools/tools/database_tool.rb +1 -1
- data/lib/shared_tools/tools/diff_tool.rb +48 -0
- data/lib/shared_tools/tools/disk/directory_create_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/directory_delete_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/directory_list_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/directory_move_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_create_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_delete_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_move_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_read_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_replace_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_write_tool.rb +1 -1
- data/lib/shared_tools/tools/disk_tool.rb +1 -1
- data/lib/shared_tools/tools/dns_tool.rb +1 -1
- data/lib/shared_tools/tools/doc/docx_reader_tool.rb +1 -1
- data/lib/shared_tools/tools/doc/pdf_reader_tool.rb +1 -1
- data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +1 -1
- data/lib/shared_tools/tools/doc/text_reader_tool.rb +1 -1
- data/lib/shared_tools/tools/doc_tool.rb +1 -1
- data/lib/shared_tools/tools/docker/compose_run_tool.rb +1 -1
- data/lib/shared_tools/tools/download_file_tool.rb +87 -0
- data/lib/shared_tools/tools/enabler.rb +2 -2
- data/lib/shared_tools/tools/error_handling_tool.rb +1 -1
- data/lib/shared_tools/tools/eval/python_eval_tool.rb +1 -1
- data/lib/shared_tools/tools/eval/ruby_eval_tool.rb +1 -1
- data/lib/shared_tools/tools/eval/shell_eval_tool.rb +27 -6
- data/lib/shared_tools/tools/eval_tool.rb +1 -1
- data/lib/shared_tools/tools/gem_tool.rb +157 -0
- data/lib/shared_tools/tools/git/add_tool.rb +73 -0
- data/lib/shared_tools/tools/git/blame_tool.rb +77 -0
- data/lib/shared_tools/tools/git/branch_tool.rb +55 -0
- data/lib/shared_tools/tools/git/checkout_tool.rb +69 -0
- data/lib/shared_tools/tools/git/commit_tool.rb +73 -0
- data/lib/shared_tools/tools/git/diff_tool.rb +69 -0
- data/lib/shared_tools/tools/git/grep_tool.rb +82 -0
- data/lib/shared_tools/tools/git/helpers.rb +70 -0
- data/lib/shared_tools/tools/git/log_tool.rb +71 -0
- data/lib/shared_tools/tools/git/show_tool.rb +66 -0
- data/lib/shared_tools/tools/git/status_tool.rb +46 -0
- data/lib/shared_tools/tools/git.rb +18 -0
- data/lib/shared_tools/tools/git_tool.rb +148 -0
- data/lib/shared_tools/tools/glob_tool.rb +75 -0
- data/lib/shared_tools/tools/http_helpers.rb +119 -0
- data/lib/shared_tools/tools/http_request_tool.rb +91 -0
- data/lib/shared_tools/tools/json_query_tool.rb +81 -0
- data/lib/shared_tools/tools/lint_tool.rb +93 -0
- data/lib/shared_tools/tools/multi_edit_tool.rb +134 -0
- data/lib/shared_tools/tools/notification_tool.rb +1 -1
- data/lib/shared_tools/tools/parse_ruby_tool.rb +127 -0
- data/lib/shared_tools/tools/process_kill_tool.rb +55 -0
- data/lib/shared_tools/tools/process_list_tool.rb +41 -0
- data/lib/shared_tools/tools/process_output_tool.rb +69 -0
- data/lib/shared_tools/tools/process_start_tool.rb +0 -0
- data/lib/shared_tools/tools/python_tests_tool.rb +105 -0
- data/lib/shared_tools/tools/replace_in_files_tool.rb +166 -0
- data/lib/shared_tools/tools/run_tests_tool.rb +108 -0
- data/lib/shared_tools/tools/search_codebase_tool.rb +222 -0
- data/lib/shared_tools/tools/secure_tool_template.rb +1 -1
- data/lib/shared_tools/tools/system_info_tool.rb +1 -1
- data/lib/shared_tools/tools/todo_write_tool.rb +81 -0
- data/lib/shared_tools/tools/toml_query_tool.rb +81 -0
- data/lib/shared_tools/tools/toolchain_helpers.rb +64 -0
- data/lib/shared_tools/tools/tree_tool.rb +100 -0
- data/lib/shared_tools/tools/weather_tool.rb +12 -2
- data/lib/shared_tools/tools/web_fetch_tool.rb +85 -0
- data/lib/shared_tools/tools/workflow_manager_tool.rb +1 -1
- data/lib/shared_tools/tools/yaml_query_tool.rb +86 -0
- data/lib/shared_tools/tree_tool.rb +4 -0
- data/lib/shared_tools/url_guard.rb +106 -0
- data/lib/shared_tools/utilities.rb +2 -2
- data/lib/shared_tools/version.rb +1 -1
- data/lib/shared_tools/web_fetch_tool.rb +4 -0
- data/lib/shared_tools/yaml_query_tool.rb +4 -0
- data/lib/shared_tools.rb +1 -1
- metadata +516 -394
- data/lib/shared_tools/mcp/streamable_http_patch.rb +0 -42
- 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,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,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
|