shared_tools 0.4.4 → 0.7.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 +40 -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 +2 -2
- data/lib/shared_tools/tools/browser/inspect_tool.rb +2 -2
- data/lib/shared_tools/tools/browser/page_inspect_tool.rb +2 -2
- data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +2 -2
- data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +2 -2
- data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +2 -2
- data/lib/shared_tools/tools/browser/visit_tool.rb +2 -2
- data/lib/shared_tools/tools/browser_tool.rb +2 -2
- data/lib/shared_tools/tools/bundle_tool.rb +96 -0
- data/lib/shared_tools/tools/calculator_tool.rb +2 -2
- data/lib/shared_tools/tools/clipboard_tool.rb +2 -2
- data/lib/shared_tools/tools/composite_analysis_tool.rb +2 -2
- data/lib/shared_tools/tools/computer_tool.rb +2 -2
- data/lib/shared_tools/tools/cron_tool.rb +2 -2
- 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 +3 -3
- data/lib/shared_tools/tools/data_science_kit.rb +2 -2
- data/lib/shared_tools/tools/database_query_tool.rb +2 -2
- data/lib/shared_tools/tools/database_tool.rb +2 -2
- data/lib/shared_tools/tools/diff_tool.rb +48 -0
- data/lib/shared_tools/tools/disk/directory_create_tool.rb +2 -2
- data/lib/shared_tools/tools/disk/directory_delete_tool.rb +2 -2
- data/lib/shared_tools/tools/disk/directory_list_tool.rb +2 -2
- data/lib/shared_tools/tools/disk/directory_move_tool.rb +2 -2
- data/lib/shared_tools/tools/disk/file_create_tool.rb +2 -2
- data/lib/shared_tools/tools/disk/file_delete_tool.rb +2 -2
- data/lib/shared_tools/tools/disk/file_move_tool.rb +2 -2
- data/lib/shared_tools/tools/disk/file_read_tool.rb +2 -2
- data/lib/shared_tools/tools/disk/file_replace_tool.rb +2 -2
- data/lib/shared_tools/tools/disk/file_write_tool.rb +2 -2
- data/lib/shared_tools/tools/disk_tool.rb +2 -2
- data/lib/shared_tools/tools/dns_tool.rb +2 -2
- data/lib/shared_tools/tools/doc/docx_reader_tool.rb +2 -2
- data/lib/shared_tools/tools/doc/pdf_reader_tool.rb +2 -2
- data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +2 -2
- data/lib/shared_tools/tools/doc/text_reader_tool.rb +2 -2
- data/lib/shared_tools/tools/doc_tool.rb +2 -2
- data/lib/shared_tools/tools/docker/compose_run_tool.rb +2 -2
- 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 +2 -2
- data/lib/shared_tools/tools/eval/python_eval_tool.rb +2 -2
- data/lib/shared_tools/tools/eval/ruby_eval_tool.rb +2 -2
- data/lib/shared_tools/tools/eval/shell_eval_tool.rb +2 -2
- data/lib/shared_tools/tools/eval_tool.rb +2 -2
- 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 +2 -2
- 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 +182 -51
- data/lib/shared_tools/tools/secure_tool_template.rb +2 -2
- data/lib/shared_tools/tools/system_info_tool.rb +2 -2
- 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 +2 -2
- data/lib/shared_tools/tools/web_fetch_tool.rb +85 -0
- data/lib/shared_tools/tools/workflow_manager_tool.rb +2 -2
- 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 +532 -409
- data/lib/shared_tools/mcp/streamable_http_patch.rb +0 -42
- data/lib/shared_tools/tools/version.rb +0 -7
|
@@ -1,56 +1,85 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "find"
|
|
4
|
+
require "pathname"
|
|
4
5
|
|
|
5
6
|
module SharedTools
|
|
6
7
|
module Tools
|
|
7
|
-
# Search for a
|
|
8
|
+
# Search file contents for a regular expression under a directory and
|
|
9
|
+
# return "path:line: text" hits. Pure Ruby (no `rg`/`grep` dependency).
|
|
8
10
|
# Read-only — no authorization prompt required.
|
|
9
11
|
#
|
|
12
|
+
# The pattern comes from an LLM, so two abuse vectors are guarded against:
|
|
13
|
+
# - ReDoS: the regex is compiled with a per-match timeout, so
|
|
14
|
+
# catastrophic backtracking can't hang the process.
|
|
15
|
+
# - Runaway scans: results are capped at max_results (default 50, max
|
|
16
|
+
# 500), and the walk skips binary files, symlinks, oversized files,
|
|
17
|
+
# and common noise directories (.git, node_modules, tmp, ...).
|
|
18
|
+
#
|
|
10
19
|
# @example
|
|
11
20
|
# tool = SharedTools::Tools::SearchCodebaseTool.new
|
|
12
|
-
# tool.execute(
|
|
13
|
-
# tool.execute(
|
|
21
|
+
# tool.execute(pattern: "def execute")
|
|
22
|
+
# tool.execute(pattern: "RubyLLM", glob: "*.rb", max_results: 20)
|
|
23
|
+
# tool.execute(pattern: "TODO", context: 2) # 2 lines of context around each match
|
|
14
24
|
class SearchCodebaseTool < ::RubyLLM::Tool
|
|
15
25
|
MAX_RESULTS_CAP = 500
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
DEFAULT_MAX_RESULTS = 50
|
|
27
|
+
MAX_FILE_BYTES = 5 * 1024 * 1024
|
|
28
|
+
MAX_CONTEXT = 50
|
|
29
|
+
REGEX_TIMEOUT = 2
|
|
30
|
+
IGNORED_DIRS = %w[.git .hg .svn node_modules .bundle tmp].freeze
|
|
31
|
+
|
|
32
|
+
def self.tool_name = 'search_codebase'
|
|
33
|
+
|
|
34
|
+
description "Search file contents for a regular expression under a directory. Returns " \
|
|
35
|
+
"'path:line: text' for each match, or context blocks when context/before/after " \
|
|
36
|
+
"is set. Optionally restrict to files whose name matches a glob, and/or match " \
|
|
37
|
+
"case-insensitively. Results are capped, ReDoS-guarded, and require no external " \
|
|
38
|
+
"search binary."
|
|
39
|
+
|
|
40
|
+
parameters do
|
|
41
|
+
string :pattern, description: "Regular expression to search for."
|
|
23
42
|
string :path, description: "Directory to search in (default: current directory)", required: false
|
|
24
|
-
string :
|
|
25
|
-
|
|
43
|
+
string :glob, description: "Only search files whose basename matches this glob, e.g. '*.rb'. Optional.", required: false
|
|
44
|
+
boolean :ignore_case, description: "Case-insensitive match. Default false.", required: false
|
|
45
|
+
integer :context, description: "Lines of context to show before AND after each match (like grep -C). Optional.", required: false
|
|
46
|
+
integer :before, description: "Lines of context before each match (like grep -B). Overrides context. Optional.", required: false
|
|
47
|
+
integer :after, description: "Lines of context after each match (like grep -A). Overrides context. Optional.", required: false
|
|
48
|
+
integer :max_results, description: "Maximum number of matches to return (default #{DEFAULT_MAX_RESULTS}, max #{MAX_RESULTS_CAP}).", required: false
|
|
26
49
|
end
|
|
27
50
|
|
|
28
51
|
def initialize(logger: nil)
|
|
29
52
|
@logger = logger || RubyLLM.logger
|
|
30
53
|
end
|
|
31
54
|
|
|
32
|
-
def execute(
|
|
33
|
-
@logger.info("SearchCodebaseTool#execute
|
|
55
|
+
def execute(pattern:, path: ".", glob: nil, ignore_case: false, context: nil, before: nil, after: nil, max_results: DEFAULT_MAX_RESULTS)
|
|
56
|
+
@logger.info("SearchCodebaseTool#execute pattern=#{pattern.inspect} path=#{path} glob=#{glob.inspect}")
|
|
34
57
|
|
|
35
|
-
return { error: "Search
|
|
58
|
+
return { error: "Search pattern cannot be empty" } if pattern.to_s.strip.empty?
|
|
36
59
|
|
|
37
|
-
max_results = [[max_results.to_i, 1].max, MAX_RESULTS_CAP].min
|
|
38
60
|
search_path = File.expand_path(path)
|
|
39
|
-
|
|
40
61
|
return { error: "Path not found: #{path}" } unless File.exist?(search_path)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
62
|
+
return { error: "Not a directory: #{path}" } unless File.directory?(search_path)
|
|
63
|
+
|
|
64
|
+
regex = build_regex(pattern, ignore_case)
|
|
65
|
+
max = [[max_results.to_i, 1].max, MAX_RESULTS_CAP].min
|
|
66
|
+
ctx_before = clamp_context(before.nil? ? context : before)
|
|
67
|
+
ctx_after = clamp_context(after.nil? ? context : after)
|
|
68
|
+
|
|
69
|
+
matches, truncated =
|
|
70
|
+
if ctx_before.zero? && ctx_after.zero?
|
|
71
|
+
gather(search_path, regex, glob, max)
|
|
72
|
+
else
|
|
73
|
+
gather_with_context(search_path, regex, glob, ctx_before, ctx_after, max)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
{ matches: matches, count: matches.size, truncated: truncated, tool: "ruby" }
|
|
77
|
+
rescue Regexp::TimeoutError
|
|
78
|
+
@logger.error("SearchCodebaseTool regex timed out: #{pattern.inspect}")
|
|
79
|
+
{ error: "regex timed out (possible catastrophic backtracking); simplify the pattern" }
|
|
80
|
+
rescue RegexpError => e
|
|
81
|
+
@logger.error("SearchCodebaseTool invalid regex: #{e.message}")
|
|
82
|
+
{ error: "invalid regex: #{e.message}" }
|
|
54
83
|
rescue => e
|
|
55
84
|
@logger.error("SearchCodebaseTool error: #{e.message}")
|
|
56
85
|
{ error: e.message }
|
|
@@ -58,33 +87,135 @@ module SharedTools
|
|
|
58
87
|
|
|
59
88
|
private
|
|
60
89
|
|
|
61
|
-
def
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
90
|
+
def build_regex(pattern, ignore_case)
|
|
91
|
+
options = ignore_case ? Regexp::IGNORECASE : 0
|
|
92
|
+
Regexp.new(pattern.to_s, options, timeout: REGEX_TIMEOUT)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def clamp_context(value)
|
|
96
|
+
n = value.to_i
|
|
97
|
+
return 0 if n <= 0
|
|
98
|
+
|
|
99
|
+
[n, MAX_CONTEXT].min
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def gather(root, regex, glob, max)
|
|
103
|
+
base = Pathname.new(root)
|
|
104
|
+
matches = []
|
|
105
|
+
truncated = false
|
|
106
|
+
|
|
107
|
+
catch(:done) do
|
|
108
|
+
walk(root, glob) do |file|
|
|
109
|
+
scan(file, regex) do |lineno, text|
|
|
110
|
+
rel = Pathname.new(file).relative_path_from(base).to_s
|
|
111
|
+
matches << "#{rel}:#{lineno}: #{text.strip}"
|
|
112
|
+
if matches.size >= max
|
|
113
|
+
truncated = true
|
|
114
|
+
throw :done
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
66
118
|
end
|
|
119
|
+
|
|
120
|
+
[matches, truncated]
|
|
67
121
|
end
|
|
68
122
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
123
|
+
# Like gather, but expands each match with before/after context lines,
|
|
124
|
+
# merges overlapping/adjacent ranges per file, and returns formatted
|
|
125
|
+
# blocks. Match lines use "path:line: text"; context lines use
|
|
126
|
+
# "path-line- text".
|
|
127
|
+
def gather_with_context(root, regex, glob, ctx_before, ctx_after, max)
|
|
128
|
+
base = Pathname.new(root)
|
|
129
|
+
blocks = []
|
|
130
|
+
total = 0
|
|
131
|
+
truncated = false
|
|
132
|
+
|
|
133
|
+
catch(:done) do
|
|
134
|
+
walk(root, glob) do |file|
|
|
135
|
+
next if binary?(file)
|
|
136
|
+
|
|
137
|
+
lines = read_lines(file)
|
|
138
|
+
next if lines.nil?
|
|
139
|
+
|
|
140
|
+
match_idxs = []
|
|
141
|
+
lines.each_with_index { |line, i| match_idxs << i if regex.match?(line) }
|
|
142
|
+
next if match_idxs.empty?
|
|
143
|
+
|
|
144
|
+
taken = match_idxs.first(max - total)
|
|
145
|
+
truncated = true if taken.size < match_idxs.size
|
|
146
|
+
match_set = {}
|
|
147
|
+
taken.each { |i| match_set[i] = true }
|
|
148
|
+
|
|
149
|
+
rel = Pathname.new(file).relative_path_from(base).to_s
|
|
150
|
+
ranges = taken.map { |i| [[i - ctx_before, 0].max, [i + ctx_after, lines.size - 1].min] }
|
|
151
|
+
merge_ranges(ranges).each do |lo, hi|
|
|
152
|
+
block = (lo..hi).map do |i|
|
|
153
|
+
sep = match_set[i] ? ":" : "-"
|
|
154
|
+
"#{rel}#{sep}#{i + 1}#{sep} #{lines[i].to_s.strip}"
|
|
155
|
+
end
|
|
156
|
+
blocks << block.join("\n")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
total += taken.size
|
|
160
|
+
throw :done if total >= max
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
[blocks, truncated]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def read_lines(file)
|
|
168
|
+
File.readlines(file).map(&:scrub)
|
|
169
|
+
rescue ArgumentError, SystemCallError
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def merge_ranges(ranges)
|
|
174
|
+
merged = []
|
|
175
|
+
ranges.sort_by(&:first).each do |lo, hi|
|
|
176
|
+
if !merged.empty? && lo <= merged.last[1] + 1
|
|
177
|
+
merged.last[1] = [merged.last[1], hi].max
|
|
178
|
+
else
|
|
179
|
+
merged << [lo, hi]
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
merged
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def walk(root, glob)
|
|
186
|
+
Find.find(root) do |entry|
|
|
187
|
+
basename = File.basename(entry)
|
|
188
|
+
|
|
189
|
+
if File.directory?(entry)
|
|
190
|
+
Find.prune if IGNORED_DIRS.include?(basename)
|
|
191
|
+
next
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
next if File.symlink?(entry)
|
|
195
|
+
next if glob && !File.fnmatch?(glob, basename, File::FNM_PATHNAME)
|
|
196
|
+
next unless File.file?(entry)
|
|
197
|
+
next if File.size(entry) > MAX_FILE_BYTES
|
|
198
|
+
|
|
199
|
+
yield entry
|
|
200
|
+
end
|
|
75
201
|
end
|
|
76
202
|
|
|
77
|
-
def
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
203
|
+
def scan(file, regex)
|
|
204
|
+
return if binary?(file)
|
|
205
|
+
|
|
206
|
+
File.foreach(file).with_index(1) do |line, lineno|
|
|
207
|
+
safe = line.scrub
|
|
208
|
+
yield(lineno, safe) if regex.match?(safe)
|
|
209
|
+
end
|
|
210
|
+
rescue ArgumentError
|
|
211
|
+
# Unreadable as text (encoding) — skip the file rather than fail.
|
|
212
|
+
nil
|
|
83
213
|
end
|
|
84
214
|
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
|
|
215
|
+
def binary?(file)
|
|
216
|
+
File.binread(file, 1024).to_s.include?("".b)
|
|
217
|
+
rescue StandardError
|
|
218
|
+
false
|
|
88
219
|
end
|
|
89
220
|
end
|
|
90
221
|
end
|
|
@@ -6,7 +6,7 @@ require 'securerandom'
|
|
|
6
6
|
module SharedTools
|
|
7
7
|
module Tools
|
|
8
8
|
class SecureToolTemplate < RubyLLM::Tool
|
|
9
|
-
def self.
|
|
9
|
+
def self.tool_name = 'secure_tool_template'
|
|
10
10
|
|
|
11
11
|
description <<~'DESCRIPTION'
|
|
12
12
|
Reference template demonstrating comprehensive security best practices for safe tool development.
|
|
@@ -28,7 +28,7 @@ module SharedTools
|
|
|
28
28
|
sensitive systems, handle user data, or perform privileged operations.
|
|
29
29
|
DESCRIPTION
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
parameters do
|
|
32
32
|
string :user_input, description: <<~DESC.strip
|
|
33
33
|
User-provided input string that will be processed with comprehensive security validation.
|
|
34
34
|
The input undergoes multiple security checks:
|
|
@@ -11,7 +11,7 @@ module SharedTools
|
|
|
11
11
|
# tool.execute # all categories
|
|
12
12
|
# tool.execute(category: 'cpu') # CPU only
|
|
13
13
|
class SystemInfoTool < ::RubyLLM::Tool
|
|
14
|
-
def self.
|
|
14
|
+
def self.tool_name = 'system_info'
|
|
15
15
|
|
|
16
16
|
description <<~DESC
|
|
17
17
|
Retrieve system information from the local machine.
|
|
@@ -26,7 +26,7 @@ module SharedTools
|
|
|
26
26
|
- 'all' (default) — All of the above combined
|
|
27
27
|
DESC
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
parameters do
|
|
30
30
|
string :category, required: false, description: <<~DESC.strip
|
|
31
31
|
Info category. Options: 'os', 'cpu', 'memory', 'disk', 'network', 'ruby', 'all' (default).
|
|
32
32
|
DESC
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../shared_tools"
|
|
4
|
+
|
|
5
|
+
module SharedTools
|
|
6
|
+
module Tools
|
|
7
|
+
# Tracks a task list for multi-step work. Each call replaces the whole
|
|
8
|
+
# list (the standard agent pattern) and renders it back. State lives on
|
|
9
|
+
# the tool instance, so it persists across calls within a single chat
|
|
10
|
+
# session (when one instance is registered with the chat). Read-only in
|
|
11
|
+
# the sense that it never touches the filesystem or network — no
|
|
12
|
+
# authorization prompt required.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# tool = SharedTools::Tools::TodoWriteTool.new
|
|
16
|
+
# tool.execute(todos: [
|
|
17
|
+
# { content: "Write the tool", status: "completed" },
|
|
18
|
+
# { content: "Write the tests", status: "in_progress" }
|
|
19
|
+
# ])
|
|
20
|
+
class TodoWriteTool < ::RubyLLM::Tool
|
|
21
|
+
STATUSES = %w[pending in_progress completed].freeze
|
|
22
|
+
MARKS = { "pending" => "[ ]", "in_progress" => "[~]", "completed" => "[x]" }.freeze
|
|
23
|
+
|
|
24
|
+
def self.tool_name = 'todo_write'
|
|
25
|
+
|
|
26
|
+
description "Maintain a task list for multi-step work. Pass the full list of todos; each call " \
|
|
27
|
+
"replaces the previous list. Each todo has a content string and a status of " \
|
|
28
|
+
"pending, in_progress, or completed."
|
|
29
|
+
|
|
30
|
+
parameters do
|
|
31
|
+
array :todos, description: "The full task list, replacing the previous one." do
|
|
32
|
+
object do
|
|
33
|
+
string :content, description: "The task description."
|
|
34
|
+
string :status, description: "One of: pending, in_progress, completed."
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param logger [Logger] optional logger
|
|
40
|
+
def initialize(logger: nil)
|
|
41
|
+
@todos = []
|
|
42
|
+
@logger = logger || RubyLLM.logger
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @param todos [Array<Hash>]
|
|
46
|
+
#
|
|
47
|
+
# @return [String, Hash]
|
|
48
|
+
def execute(todos:)
|
|
49
|
+
@logger.info("#{self.class.name}#execute todos=#{Array(todos).size}")
|
|
50
|
+
|
|
51
|
+
return { error: "todos must be an array" } unless todos.is_a?(Array)
|
|
52
|
+
|
|
53
|
+
normalized = todos.map { |todo| normalize(todo) }
|
|
54
|
+
invalid = normalized.find { |todo| !STATUSES.include?(todo[:status]) }
|
|
55
|
+
return { error: "invalid status: #{invalid[:status].inspect} (use #{STATUSES.join(', ')})" } if invalid
|
|
56
|
+
|
|
57
|
+
@todos = normalized
|
|
58
|
+
render
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def normalize(todo)
|
|
64
|
+
hash = todo.is_a?(Hash) ? todo : {}
|
|
65
|
+
{
|
|
66
|
+
content: (hash["content"] || hash[:content]).to_s,
|
|
67
|
+
status: (hash["status"] || hash[:status] || "pending").to_s.strip.downcase
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def render
|
|
72
|
+
return "Task list is empty." if @todos.empty?
|
|
73
|
+
|
|
74
|
+
done = @todos.count { |todo| todo[:status] == "completed" }
|
|
75
|
+
lines = ["Tasks (#{done}/#{@todos.size} complete):"]
|
|
76
|
+
@todos.each { |todo| lines << "#{MARKS[todo[:status]]} #{todo[:content]}" }
|
|
77
|
+
lines.join("\n")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../../shared_tools'
|
|
5
|
+
|
|
6
|
+
module SharedTools
|
|
7
|
+
module Tools
|
|
8
|
+
# Parses TOML (from a file or an inline string) and either pretty-prints
|
|
9
|
+
# it (as JSON, for readability) or extracts a value with a dot/bracket
|
|
10
|
+
# path. In-process, read-only, no external gem dependency — handy for
|
|
11
|
+
# Cargo.toml, pyproject.toml, and similar config.
|
|
12
|
+
#
|
|
13
|
+
# Path syntax matches JsonQueryTool / YamlQueryTool: dependencies.serde.version,
|
|
14
|
+
# products[0].name, products[].name
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# tool = SharedTools::Tools::TomlQueryTool.new
|
|
18
|
+
# tool.execute(path: "./Cargo.toml", query: "dependencies.serde.version")
|
|
19
|
+
class TomlQueryTool < ::RubyLLM::Tool
|
|
20
|
+
MAX_BYTES = 5 * 1024 * 1024
|
|
21
|
+
|
|
22
|
+
def self.tool_name = 'toml_query'
|
|
23
|
+
|
|
24
|
+
description "Query TOML with a path expression, or pretty-print it. Provide either a file " \
|
|
25
|
+
"path or an inline toml string. Path syntax: keys separated by dots, [n] for " \
|
|
26
|
+
"array index, [] to map over an array (e.g. products[].name)."
|
|
27
|
+
|
|
28
|
+
parameters do
|
|
29
|
+
string :path, description: "TOML file to read. Provide this or toml.", required: false
|
|
30
|
+
string :toml, description: "Inline TOML string. Provide this or path.", required: false
|
|
31
|
+
string :query, description: "Path expression to extract, e.g. 'dependencies.serde.version'. Omit to pretty-print. Optional.", required: false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param logger [Logger] optional logger
|
|
35
|
+
def initialize(logger: nil)
|
|
36
|
+
@logger = logger || RubyLLM.logger
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param path [String, nil]
|
|
40
|
+
# @param toml [String, nil]
|
|
41
|
+
# @param query [String, nil]
|
|
42
|
+
#
|
|
43
|
+
# @return [String, Hash]
|
|
44
|
+
def execute(path: nil, toml: nil, query: nil)
|
|
45
|
+
@logger.info("#{self.class.name}#execute path=#{path.inspect} query=#{query.inspect}")
|
|
46
|
+
|
|
47
|
+
source = load_source(path, toml)
|
|
48
|
+
return source if source.is_a?(Hash) # error
|
|
49
|
+
|
|
50
|
+
data = SharedTools::TomlParser.parse(source)
|
|
51
|
+
result = query.to_s.strip.empty? ? data : SharedTools::DataPath.query(data, query)
|
|
52
|
+
JSON.pretty_generate(result)
|
|
53
|
+
rescue SharedTools::TomlParser::ParseError => e
|
|
54
|
+
@logger.error("#{self.class.name} invalid TOML: #{e.message}")
|
|
55
|
+
{ error: "invalid TOML: #{e.message}" }
|
|
56
|
+
rescue SharedTools::DataPath::Error => e
|
|
57
|
+
@logger.error("#{self.class.name} bad query: #{e.message}")
|
|
58
|
+
{ error: e.message }
|
|
59
|
+
rescue => e
|
|
60
|
+
@logger.error("#{self.class.name} failed: #{e.message}")
|
|
61
|
+
{ error: e.message }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def load_source(path, toml)
|
|
67
|
+
if path && !path.to_s.empty?
|
|
68
|
+
return { error: "file not found: #{path}" } unless File.exist?(path)
|
|
69
|
+
return { error: "not a file: #{path}" } unless File.file?(path)
|
|
70
|
+
return { error: "file too large (> #{MAX_BYTES} bytes)" } if File.size(path) > MAX_BYTES
|
|
71
|
+
|
|
72
|
+
File.read(path).scrub
|
|
73
|
+
elsif toml && !toml.to_s.empty?
|
|
74
|
+
toml.to_s
|
|
75
|
+
else
|
|
76
|
+
{ error: "provide either path or toml" }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require_relative "../../shared_tools"
|
|
5
|
+
require_relative "../process_runner"
|
|
6
|
+
|
|
7
|
+
module SharedTools
|
|
8
|
+
module Tools
|
|
9
|
+
# Shared behavior for the Ruby toolchain tools (BundleTool, LintTool,
|
|
10
|
+
# RunTestsTool, PythonTestsTool). Mixing tools must set @root in
|
|
11
|
+
# #initialize.
|
|
12
|
+
#
|
|
13
|
+
# Unlike BashTool, these run in @root and inherit the full host
|
|
14
|
+
# environment. That's deliberate: tools like bundler, rbenv/rvm, and the
|
|
15
|
+
# test/lint binaries rely on the Ruby environment (PATH shims, GEM_HOME,
|
|
16
|
+
# BUNDLE_*) to resolve correctly, and these are trusted dev commands the
|
|
17
|
+
# caller opted into (they all require SharedTools.execute? authorization).
|
|
18
|
+
module ToolchainHelpers
|
|
19
|
+
class CommandMissing < StandardError; end
|
|
20
|
+
|
|
21
|
+
def gemfile?
|
|
22
|
+
File.file?(File.join(@root, "Gemfile"))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Runs argv in @root. With use_bundle, prefixes `bundle exec` when a
|
|
26
|
+
# Gemfile is present. Returns [out, err, status]; raises CommandMissing
|
|
27
|
+
# if the executable can't be found.
|
|
28
|
+
def run_in_project(argv, use_bundle: false, timeout: 120)
|
|
29
|
+
command = use_bundle && gemfile? ? ["bundle", "exec", *argv] : argv
|
|
30
|
+
SharedTools::ProcessRunner.capture(
|
|
31
|
+
command,
|
|
32
|
+
env: {}, # inherit the full host environment
|
|
33
|
+
chdir: @root,
|
|
34
|
+
timeout: timeout,
|
|
35
|
+
unsetenv_others: false
|
|
36
|
+
)
|
|
37
|
+
rescue Errno::ENOENT
|
|
38
|
+
raise CommandMissing, command.first
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Maps a finished toolchain run onto a return value. Non-zero exit is
|
|
42
|
+
# NOT treated as a tool error here — failing tests / lint offenses are
|
|
43
|
+
# results the caller needs to see, with a headline prepended.
|
|
44
|
+
def toolchain_output(out, err, status, pass_label:, fail_label:, timeout:)
|
|
45
|
+
return { error: "timed out after #{timeout}s" } if status == :timeout
|
|
46
|
+
|
|
47
|
+
combined = [out, err].map(&:to_s).reject(&:empty?).join("\n")
|
|
48
|
+
headline = status.exitstatus.zero? ? pass_label : fail_label
|
|
49
|
+
"#{headline}\n\n#{combined}".strip
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Resolve a path param to a root-relative path, rejecting escapes.
|
|
53
|
+
def jail_relative(path)
|
|
54
|
+
return nil if path.nil? || path.to_s.empty?
|
|
55
|
+
|
|
56
|
+
root = Pathname.new(File.expand_path(@root))
|
|
57
|
+
resolved = (root + path).cleanpath
|
|
58
|
+
raise SecurityError, "path escapes root: #{path}" unless resolved.ascend.any? { |ancestor| ancestor == root }
|
|
59
|
+
|
|
60
|
+
resolved.relative_path_from(root).to_s
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../shared_tools'
|
|
4
|
+
|
|
5
|
+
module SharedTools
|
|
6
|
+
module Tools
|
|
7
|
+
# Renders a depth-limited directory tree — a quick way to see project
|
|
8
|
+
# structure without walking it one level at a time with
|
|
9
|
+
# Disk::DirectoryListTool. Read-only; ignored directories are skipped,
|
|
10
|
+
# symlinks are not followed, and the listing is capped.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# tool = SharedTools::Tools::TreeTool.new
|
|
14
|
+
# tool.execute
|
|
15
|
+
# tool.execute(path: "lib", max_depth: 2)
|
|
16
|
+
class TreeTool < ::RubyLLM::Tool
|
|
17
|
+
DEFAULT_DEPTH = 3
|
|
18
|
+
MAX_ENTRIES = 500
|
|
19
|
+
IGNORED_DIRS = %w[.git .hg .svn node_modules .bundle tmp].freeze
|
|
20
|
+
|
|
21
|
+
def self.tool_name = 'tree'
|
|
22
|
+
|
|
23
|
+
description "Show a directory tree, to a limited depth. Directories are marked with a " \
|
|
24
|
+
"trailing slash. Skips ignored directories (node_modules, .git, ...) and hidden " \
|
|
25
|
+
"entries unless show_hidden is set."
|
|
26
|
+
|
|
27
|
+
parameters do
|
|
28
|
+
integer :max_depth, description: "How many levels deep to descend (default #{DEFAULT_DEPTH}).", required: false
|
|
29
|
+
string :path, description: "Directory to start from. Defaults to the current directory.", required: false
|
|
30
|
+
boolean :show_hidden, description: "Include dot-files and dot-directories. Default false.", required: false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param logger [Logger] optional logger
|
|
34
|
+
def initialize(logger: nil)
|
|
35
|
+
@logger = logger || RubyLLM.logger
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param path [String, nil]
|
|
39
|
+
# @param max_depth [Integer]
|
|
40
|
+
# @param show_hidden [Boolean]
|
|
41
|
+
#
|
|
42
|
+
# @return [String, Hash]
|
|
43
|
+
def execute(path: nil, max_depth: DEFAULT_DEPTH, show_hidden: false)
|
|
44
|
+
@logger.info("#{self.class.name}#execute path=#{path.inspect} max_depth=#{max_depth} show_hidden=#{show_hidden}")
|
|
45
|
+
|
|
46
|
+
root = File.expand_path(path.to_s.empty? ? "." : path)
|
|
47
|
+
return { error: "not a directory: #{path || '.'}" } unless File.directory?(root)
|
|
48
|
+
|
|
49
|
+
depth = max_depth.to_i
|
|
50
|
+
depth = DEFAULT_DEPTH if depth <= 0
|
|
51
|
+
|
|
52
|
+
@count = 0
|
|
53
|
+
@truncated = false
|
|
54
|
+
lines = ["#{File.basename(root)}/"]
|
|
55
|
+
walk(root, depth, show_hidden, "", lines)
|
|
56
|
+
lines << "... (truncated at #{MAX_ENTRIES} entries)" if @truncated
|
|
57
|
+
|
|
58
|
+
lines.join("\n")
|
|
59
|
+
rescue => e
|
|
60
|
+
@logger.error("#{self.class.name} failed: #{e.message}")
|
|
61
|
+
{ error: e.message }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def walk(dir, depth_left, show_hidden, indent, lines)
|
|
67
|
+
return if depth_left <= 0 || @truncated
|
|
68
|
+
|
|
69
|
+
entries = children(dir, show_hidden)
|
|
70
|
+
entries.each_with_index do |entry, idx|
|
|
71
|
+
if @count >= MAX_ENTRIES
|
|
72
|
+
@truncated = true
|
|
73
|
+
return
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
full = File.join(dir, entry)
|
|
77
|
+
is_dir = File.directory?(full) && !File.symlink?(full)
|
|
78
|
+
connector = idx == entries.length - 1 ? "└── " : "├── "
|
|
79
|
+
lines << "#{indent}#{connector}#{entry}#{is_dir ? '/' : ''}"
|
|
80
|
+
@count += 1
|
|
81
|
+
|
|
82
|
+
next unless is_dir
|
|
83
|
+
|
|
84
|
+
child_indent = indent + (idx == entries.length - 1 ? " " : "│ ")
|
|
85
|
+
walk(full, depth_left - 1, show_hidden, child_indent, lines)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def children(dir, show_hidden)
|
|
90
|
+
entries = Dir.children(dir)
|
|
91
|
+
entries.reject! { |e| e.start_with?(".") } unless show_hidden
|
|
92
|
+
entries.reject! { |e| File.directory?(File.join(dir, e)) && IGNORED_DIRS.include?(e) }
|
|
93
|
+
# directories first, then files, each alphabetical
|
|
94
|
+
entries.sort_by { |e| [File.directory?(File.join(dir, e)) ? 0 : 1, e.downcase] }
|
|
95
|
+
rescue SystemCallError
|
|
96
|
+
[]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -11,7 +11,7 @@ end
|
|
|
11
11
|
module SharedTools
|
|
12
12
|
module Tools
|
|
13
13
|
class WeatherTool < RubyLLM::Tool
|
|
14
|
-
def self.
|
|
14
|
+
def self.tool_name = 'weather'
|
|
15
15
|
|
|
16
16
|
def available?
|
|
17
17
|
OPENWEATHERMAP_AVAILABLE
|
|
@@ -31,7 +31,7 @@ module SharedTools
|
|
|
31
31
|
puts "Temperature: #{result[:current][:temperature]}°C"
|
|
32
32
|
DESCRIPTION
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
parameters do
|
|
35
35
|
string :city, description: <<~DESC.strip
|
|
36
36
|
Name of the city for weather lookup. Can include city name only (e.g., 'London')
|
|
37
37
|
or city with country code for better accuracy (e.g., 'London,UK' or 'Paris,FR').
|