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,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../shared_tools"
|
|
4
|
+
|
|
5
|
+
module SharedTools
|
|
6
|
+
module Tools
|
|
7
|
+
# Find-and-replace across the files in root that match a glob. Literal
|
|
8
|
+
# by default; set regex: true for a pattern (with backreferences in the
|
|
9
|
+
# replacement). Binary files and ignored directories are skipped, the
|
|
10
|
+
# pattern runs under a ReDoS timeout, and every path is confined to
|
|
11
|
+
# root. Use dry_run: true to preview the impact without writing.
|
|
12
|
+
# Mutating — requires user authorization (see SharedTools.execute?).
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# tool = SharedTools::Tools::ReplaceInFilesTool.new(root: "./my-project")
|
|
16
|
+
# tool.execute(pattern: "FooBar", replacement: "BazQux", glob: "**/*.rb")
|
|
17
|
+
# tool.execute(pattern: "v(\\d+)\\.(\\d+)", replacement: 'v\1.\2-beta', regex: true, dry_run: true)
|
|
18
|
+
class ReplaceInFilesTool < ::RubyLLM::Tool
|
|
19
|
+
MAX_BYTES = 5 * 1024 * 1024
|
|
20
|
+
MAX_LISTED = 100
|
|
21
|
+
REGEX_TIMEOUT = 2
|
|
22
|
+
IGNORED_DIRS = %w[.git .hg .svn node_modules .bundle tmp].freeze
|
|
23
|
+
|
|
24
|
+
def self.name = 'replace_in_files'
|
|
25
|
+
|
|
26
|
+
description "Replace occurrences of a pattern with a replacement across files in root " \
|
|
27
|
+
"matching a glob (default '**/*'). Literal by default; set regex true to use a " \
|
|
28
|
+
"regular expression (replacement may use \\1 backreferences). Set dry_run true to " \
|
|
29
|
+
"preview without writing. Skips binary files and ignored directories."
|
|
30
|
+
|
|
31
|
+
parameters do
|
|
32
|
+
string :pattern, description: "Text (or regex if regex=true) to find."
|
|
33
|
+
string :replacement, description: "Replacement text. With regex=true, \\1 etc. refer to capture groups.", required: false
|
|
34
|
+
string :glob, description: "Glob of files to search, relative to root. Default '**/*'.", required: false
|
|
35
|
+
boolean :regex, description: "Treat pattern as a regular expression. Default false (literal).", required: false
|
|
36
|
+
boolean :ignore_case, description: "Case-insensitive matching. Default false.", required: false
|
|
37
|
+
boolean :dry_run, description: "Report what would change without writing. Default false.", required: false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param root [String] optional, defaults to the current directory
|
|
41
|
+
# @param logger [Logger] optional logger
|
|
42
|
+
def initialize(root: nil, logger: nil)
|
|
43
|
+
@root = root || Dir.pwd
|
|
44
|
+
@logger = logger || RubyLLM.logger
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param pattern [String]
|
|
48
|
+
# @param replacement [String]
|
|
49
|
+
# @param glob [String]
|
|
50
|
+
# @param regex [Boolean]
|
|
51
|
+
# @param ignore_case [Boolean]
|
|
52
|
+
# @param dry_run [Boolean]
|
|
53
|
+
#
|
|
54
|
+
# @return [String, Hash]
|
|
55
|
+
def execute(pattern:, replacement: "", glob: "**/*", regex: false, ignore_case: false, dry_run: false)
|
|
56
|
+
@logger.info("#{self.class.name}#execute pattern=#{pattern.inspect} glob=#{glob.inspect} regex=#{regex} dry_run=#{dry_run}")
|
|
57
|
+
|
|
58
|
+
return { error: "pattern must not be empty" } if pattern.to_s.empty?
|
|
59
|
+
|
|
60
|
+
matcher = build_matcher(pattern, regex, ignore_case)
|
|
61
|
+
root = File.realpath(@root)
|
|
62
|
+
|
|
63
|
+
candidates = candidate_files(glob, root)
|
|
64
|
+
preview = candidates.filter_map do |abs, rel|
|
|
65
|
+
content = File.read(abs)
|
|
66
|
+
next unless text?(content)
|
|
67
|
+
|
|
68
|
+
count, = substitute(content, matcher, replacement.to_s, regex)
|
|
69
|
+
[abs, rel, count] unless count.zero?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
return format_summary([], 0, dry_run) if preview.empty?
|
|
73
|
+
|
|
74
|
+
unless dry_run
|
|
75
|
+
allowed = SharedTools.execute?(tool: self.class.to_s, stuff: "Replace #{preview.sum { |_, _, c| c }} occurrence(s) across #{preview.size} file(s)")
|
|
76
|
+
unless allowed
|
|
77
|
+
@logger.warn("User declined to replace in files")
|
|
78
|
+
return { error: "User declined to replace in files" }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
changed = []
|
|
83
|
+
total = 0
|
|
84
|
+
preview.each do |abs, rel, _count|
|
|
85
|
+
content = File.read(abs)
|
|
86
|
+
count, updated = substitute(content, matcher, replacement.to_s, regex)
|
|
87
|
+
next if count.zero?
|
|
88
|
+
|
|
89
|
+
total += count
|
|
90
|
+
changed << [rel, count]
|
|
91
|
+
File.write(abs, updated) unless dry_run
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
format_summary(changed, total, dry_run)
|
|
95
|
+
rescue Regexp::TimeoutError
|
|
96
|
+
@logger.error("#{self.class.name}: regex timed out")
|
|
97
|
+
{ error: "pattern timed out (possible ReDoS); narrow the pattern" }
|
|
98
|
+
rescue RegexpError => e
|
|
99
|
+
@logger.error("#{self.class.name}: invalid regex: #{e.message}")
|
|
100
|
+
{ error: "invalid regular expression: #{e.message}" }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def build_matcher(pattern, regex, ignore_case)
|
|
106
|
+
flags = ignore_case ? Regexp::IGNORECASE : 0
|
|
107
|
+
src = regex ? pattern.to_s : Regexp.escape(pattern.to_s)
|
|
108
|
+
Regexp.new(src, flags, timeout: REGEX_TIMEOUT)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def candidate_files(glob, root)
|
|
112
|
+
Dir.glob(File.join(root, glob)).filter_map do |abs|
|
|
113
|
+
next unless File.file?(abs)
|
|
114
|
+
|
|
115
|
+
real = begin
|
|
116
|
+
File.realpath(abs)
|
|
117
|
+
rescue StandardError
|
|
118
|
+
next
|
|
119
|
+
end
|
|
120
|
+
next unless real == root || real.start_with?(root + File::SEPARATOR)
|
|
121
|
+
|
|
122
|
+
rel = real.delete_prefix(root + File::SEPARATOR)
|
|
123
|
+
next if ignored?(rel)
|
|
124
|
+
next if File.size(real) > MAX_BYTES
|
|
125
|
+
|
|
126
|
+
[real, rel]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def ignored?(rel)
|
|
131
|
+
parts = rel.split(File::SEPARATOR)
|
|
132
|
+
IGNORED_DIRS.any? { |dir| parts.include?(dir) }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def text?(content)
|
|
136
|
+
nul = 0.chr
|
|
137
|
+
content.valid_encoding? && !content.include?(nul)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def substitute(content, matcher, replacement, regex)
|
|
141
|
+
if regex
|
|
142
|
+
count = content.scan(matcher).size
|
|
143
|
+
return [0, content] if count.zero?
|
|
144
|
+
|
|
145
|
+
[count, content.gsub(matcher, replacement)]
|
|
146
|
+
else
|
|
147
|
+
count = 0
|
|
148
|
+
updated = content.gsub(matcher) { count += 1; replacement }
|
|
149
|
+
return [0, content] if count.zero?
|
|
150
|
+
|
|
151
|
+
[count, updated]
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def format_summary(changed, total, dry_run)
|
|
156
|
+
verb = dry_run ? "Would replace" : "Replaced"
|
|
157
|
+
return "#{verb} 0 occurrences (no matching files changed)." if changed.empty?
|
|
158
|
+
|
|
159
|
+
header = "#{verb} #{total} occurrence#{total == 1 ? '' : 's'} across #{changed.size} file#{changed.size == 1 ? '' : 's'}#{dry_run ? ' (dry run)' : ''}:"
|
|
160
|
+
lines = changed.first(MAX_LISTED).map { |rel, n| " #{rel} (#{n})" }
|
|
161
|
+
lines << " ... and #{changed.size - MAX_LISTED} more" if changed.size > MAX_LISTED
|
|
162
|
+
([header] + lines).join("\n")
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../shared_tools"
|
|
4
|
+
require_relative "toolchain_helpers"
|
|
5
|
+
|
|
6
|
+
module SharedTools
|
|
7
|
+
module Tools
|
|
8
|
+
# Runs a Ruby project's test suite (RSpec or Minitest) from root and
|
|
9
|
+
# returns the output with a pass/fail headline. A failing suite is a
|
|
10
|
+
# normal result (the caller needs to see it), not a tool error. Requires
|
|
11
|
+
# user authorization (see SharedTools.execute?) since it executes an
|
|
12
|
+
# external command.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# tool = SharedTools::Tools::RunTestsTool.new(root: "./my-project")
|
|
16
|
+
# tool.execute
|
|
17
|
+
# tool.execute(path: "spec/models/user_spec.rb")
|
|
18
|
+
class RunTestsTool < ::RubyLLM::Tool
|
|
19
|
+
include ToolchainHelpers
|
|
20
|
+
|
|
21
|
+
FRAMEWORKS = %w[rspec minitest].freeze
|
|
22
|
+
DEFAULT_TIMEOUT = 180
|
|
23
|
+
|
|
24
|
+
def self.name = 'run_tests'
|
|
25
|
+
|
|
26
|
+
description "Run a Ruby project's test suite from root and report results. Auto-detects RSpec " \
|
|
27
|
+
"(spec/ or .rspec) or Minitest (test/ via rake), or set framework explicitly. " \
|
|
28
|
+
"Optionally scope to a path. Uses `bundle exec` when a Gemfile exists."
|
|
29
|
+
|
|
30
|
+
parameters do
|
|
31
|
+
string :path, description: "Limit the run to this spec/test file or directory, relative to root. Optional.", required: false
|
|
32
|
+
string :framework, description: "Force the framework: rspec or minitest. Default: auto-detect.", required: false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param root [String] optional, defaults to the current directory
|
|
36
|
+
# @param logger [Logger] optional logger
|
|
37
|
+
def initialize(root: nil, logger: nil)
|
|
38
|
+
@root = root || Dir.pwd
|
|
39
|
+
@logger = logger || RubyLLM.logger
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param path [String, nil]
|
|
43
|
+
# @param framework [String, nil]
|
|
44
|
+
#
|
|
45
|
+
# @return [String, Hash]
|
|
46
|
+
def execute(path: nil, framework: nil)
|
|
47
|
+
@logger.info("#{self.class.name}#execute path=#{path.inspect} framework=#{framework.inspect}")
|
|
48
|
+
|
|
49
|
+
fw = (framework || detect_framework).to_s
|
|
50
|
+
return { error: "could not detect a test framework (no spec/ or test/); set framework" } if fw.empty?
|
|
51
|
+
return { error: "unknown framework: #{fw} (use #{FRAMEWORKS.join(', ')})" } unless FRAMEWORKS.include?(fw)
|
|
52
|
+
|
|
53
|
+
rel = jail_relative(path)
|
|
54
|
+
|
|
55
|
+
allowed = SharedTools.execute?(tool: self.class.to_s, stuff: "Run #{fw} tests#{rel ? " (#{rel})" : ''}")
|
|
56
|
+
unless allowed
|
|
57
|
+
@logger.warn("User declined to run tests")
|
|
58
|
+
return { error: "User declined to run tests" }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
out, err, status = fw == "rspec" ? run_rspec(rel) : run_minitest(rel)
|
|
62
|
+
toolchain_output(out, err, status,
|
|
63
|
+
pass_label: summarize(out, err, "TESTS PASSED"),
|
|
64
|
+
fail_label: summarize(out, err, "TESTS FAILED"),
|
|
65
|
+
timeout: DEFAULT_TIMEOUT)
|
|
66
|
+
rescue SecurityError => e
|
|
67
|
+
@logger.error("#{self.class.name} path denied: #{e.message}")
|
|
68
|
+
{ error: e.message }
|
|
69
|
+
rescue CommandMissing => e
|
|
70
|
+
@logger.error("#{self.class.name}: #{e.message} is not available")
|
|
71
|
+
{ error: "#{e.message} is not available (is it in the bundle / installed?)" }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def detect_framework
|
|
77
|
+
return "rspec" if File.directory?(File.join(@root, "spec")) || File.exist?(File.join(@root, ".rspec"))
|
|
78
|
+
return "minitest" if File.directory?(File.join(@root, "test"))
|
|
79
|
+
|
|
80
|
+
""
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def run_rspec(rel)
|
|
84
|
+
args = ["rspec"]
|
|
85
|
+
args << rel if rel
|
|
86
|
+
run_in_project(args, use_bundle: true, timeout: DEFAULT_TIMEOUT)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def run_minitest(rel)
|
|
90
|
+
args = ["rake", "test"]
|
|
91
|
+
args << "TEST=#{rel}" if rel
|
|
92
|
+
run_in_project(args, use_bundle: true, timeout: DEFAULT_TIMEOUT)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Pull the framework's summary line into the headline when present.
|
|
96
|
+
def summarize(out, err, label)
|
|
97
|
+
text = "#{out}\n#{err}"
|
|
98
|
+
if (m = text.match(/(\d+) examples?, (\d+) failures?(?:, (\d+) errors?)?/))
|
|
99
|
+
"#{label} (#{m[0]})"
|
|
100
|
+
elsif (m = text.match(/(\d+) runs?, \d+ assertions?, (\d+) failures?, (\d+) errors?/))
|
|
101
|
+
"#{label} (#{m[0]})"
|
|
102
|
+
else
|
|
103
|
+
label
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "find"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module SharedTools
|
|
7
|
+
module Tools
|
|
8
|
+
# Search file contents for a regular expression under a directory and
|
|
9
|
+
# return "path:line: text" hits. Pure Ruby (no `rg`/`grep` dependency).
|
|
10
|
+
# Read-only — no authorization prompt required.
|
|
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
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# tool = SharedTools::Tools::SearchCodebaseTool.new
|
|
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
|
|
24
|
+
class SearchCodebaseTool < ::RubyLLM::Tool
|
|
25
|
+
MAX_RESULTS_CAP = 500
|
|
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.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."
|
|
42
|
+
string :path, description: "Directory to search in (default: current directory)", required: false
|
|
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
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def initialize(logger: nil)
|
|
52
|
+
@logger = logger || RubyLLM.logger
|
|
53
|
+
end
|
|
54
|
+
|
|
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}")
|
|
57
|
+
|
|
58
|
+
return { error: "Search pattern cannot be empty" } if pattern.to_s.strip.empty?
|
|
59
|
+
|
|
60
|
+
search_path = File.expand_path(path)
|
|
61
|
+
return { error: "Path not found: #{path}" } unless File.exist?(search_path)
|
|
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}" }
|
|
83
|
+
rescue => e
|
|
84
|
+
@logger.error("SearchCodebaseTool error: #{e.message}")
|
|
85
|
+
{ error: e.message }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
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
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
[matches, truncated]
|
|
121
|
+
end
|
|
122
|
+
|
|
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
|
|
201
|
+
end
|
|
202
|
+
|
|
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
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def binary?(file)
|
|
216
|
+
File.binread(file, 1024).to_s.include?("".b)
|
|
217
|
+
rescue StandardError
|
|
218
|
+
false
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -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:
|
|
@@ -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.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.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
|