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.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -0
  3. data/README.md +211 -7
  4. data/lib/shared_tools/apply_patch_tool.rb +4 -0
  5. data/lib/shared_tools/bash_tool.rb +4 -0
  6. data/lib/shared_tools/bundle_tool.rb +4 -0
  7. data/lib/shared_tools/command_guard.rb +35 -0
  8. data/lib/shared_tools/csv_read_tool.rb +4 -0
  9. data/lib/shared_tools/csv_write_tool.rb +4 -0
  10. data/lib/shared_tools/data_path.rb +52 -0
  11. data/lib/shared_tools/diff_tool.rb +4 -0
  12. data/lib/shared_tools/download_file_tool.rb +4 -0
  13. data/lib/shared_tools/gem_tool.rb +4 -0
  14. data/lib/shared_tools/git_tool.rb +5 -0
  15. data/lib/shared_tools/glob_tool.rb +4 -0
  16. data/lib/shared_tools/http_request_tool.rb +4 -0
  17. data/lib/shared_tools/json_query_tool.rb +4 -0
  18. data/lib/shared_tools/lint_tool.rb +4 -0
  19. data/lib/shared_tools/mcp/brave_search_client.rb +18 -16
  20. data/lib/shared_tools/mcp/chart_client.rb +18 -15
  21. data/lib/shared_tools/mcp/client_support.rb +103 -0
  22. data/lib/shared_tools/mcp/github_client.rb +23 -19
  23. data/lib/shared_tools/mcp/hugging_face_client.rb +22 -22
  24. data/lib/shared_tools/mcp/memory_client.rb +20 -15
  25. data/lib/shared_tools/mcp/notion_client.rb +21 -19
  26. data/lib/shared_tools/mcp/playwright_client.rb +19 -15
  27. data/lib/shared_tools/mcp/sequential_thinking_client.rb +17 -14
  28. data/lib/shared_tools/mcp/slack_client.rb +30 -25
  29. data/lib/shared_tools/mcp/tavily_client.rb +21 -21
  30. data/lib/shared_tools/mcp.rb +32 -41
  31. data/lib/shared_tools/multi_edit_tool.rb +4 -0
  32. data/lib/shared_tools/parse_ruby_tool.rb +4 -0
  33. data/lib/shared_tools/process_kill_tool.rb +4 -0
  34. data/lib/shared_tools/process_list_tool.rb +4 -0
  35. data/lib/shared_tools/process_output_tool.rb +4 -0
  36. data/lib/shared_tools/process_registry.rb +224 -0
  37. data/lib/shared_tools/process_runner.rb +69 -0
  38. data/lib/shared_tools/process_start_tool.rb +4 -0
  39. data/lib/shared_tools/python_tests_tool.rb +4 -0
  40. data/lib/shared_tools/replace_in_files_tool.rb +4 -0
  41. data/lib/shared_tools/ruby_outline.rb +207 -0
  42. data/lib/shared_tools/run_tests_tool.rb +4 -0
  43. data/lib/shared_tools/text_diff.rb +80 -0
  44. data/lib/shared_tools/todo_write_tool.rb +4 -0
  45. data/lib/shared_tools/toml_parser.rb +409 -0
  46. data/lib/shared_tools/toml_query_tool.rb +4 -0
  47. data/lib/shared_tools/tools/apply_patch_tool.rb +122 -0
  48. data/lib/shared_tools/tools/bash_tool.rb +102 -0
  49. data/lib/shared_tools/tools/browser/click_tool.rb +1 -1
  50. data/lib/shared_tools/tools/browser/inspect_tool.rb +1 -1
  51. data/lib/shared_tools/tools/browser/page_inspect_tool.rb +1 -1
  52. data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +1 -1
  53. data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +1 -1
  54. data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +1 -1
  55. data/lib/shared_tools/tools/browser/visit_tool.rb +1 -1
  56. data/lib/shared_tools/tools/browser_tool.rb +1 -1
  57. data/lib/shared_tools/tools/bundle_tool.rb +96 -0
  58. data/lib/shared_tools/tools/calculator_tool.rb +12 -2
  59. data/lib/shared_tools/tools/clipboard_tool.rb +1 -1
  60. data/lib/shared_tools/tools/composite_analysis_tool.rb +1 -1
  61. data/lib/shared_tools/tools/computer_tool.rb +1 -1
  62. data/lib/shared_tools/tools/cron_tool.rb +1 -1
  63. data/lib/shared_tools/tools/csv_read_tool.rb +82 -0
  64. data/lib/shared_tools/tools/csv_write_tool.rb +67 -0
  65. data/lib/shared_tools/tools/current_date_time_tool.rb +1 -1
  66. data/lib/shared_tools/tools/data_science_kit.rb +1 -1
  67. data/lib/shared_tools/tools/database_query_tool.rb +12 -2
  68. data/lib/shared_tools/tools/database_tool.rb +1 -1
  69. data/lib/shared_tools/tools/diff_tool.rb +48 -0
  70. data/lib/shared_tools/tools/disk/directory_create_tool.rb +1 -1
  71. data/lib/shared_tools/tools/disk/directory_delete_tool.rb +1 -1
  72. data/lib/shared_tools/tools/disk/directory_list_tool.rb +1 -1
  73. data/lib/shared_tools/tools/disk/directory_move_tool.rb +1 -1
  74. data/lib/shared_tools/tools/disk/file_create_tool.rb +1 -1
  75. data/lib/shared_tools/tools/disk/file_delete_tool.rb +1 -1
  76. data/lib/shared_tools/tools/disk/file_move_tool.rb +1 -1
  77. data/lib/shared_tools/tools/disk/file_read_tool.rb +1 -1
  78. data/lib/shared_tools/tools/disk/file_replace_tool.rb +1 -1
  79. data/lib/shared_tools/tools/disk/file_write_tool.rb +1 -1
  80. data/lib/shared_tools/tools/disk_tool.rb +1 -1
  81. data/lib/shared_tools/tools/dns_tool.rb +1 -1
  82. data/lib/shared_tools/tools/doc/docx_reader_tool.rb +1 -1
  83. data/lib/shared_tools/tools/doc/pdf_reader_tool.rb +1 -1
  84. data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +1 -1
  85. data/lib/shared_tools/tools/doc/text_reader_tool.rb +1 -1
  86. data/lib/shared_tools/tools/doc_tool.rb +1 -1
  87. data/lib/shared_tools/tools/docker/compose_run_tool.rb +1 -1
  88. data/lib/shared_tools/tools/download_file_tool.rb +87 -0
  89. data/lib/shared_tools/tools/enabler.rb +2 -2
  90. data/lib/shared_tools/tools/error_handling_tool.rb +1 -1
  91. data/lib/shared_tools/tools/eval/python_eval_tool.rb +1 -1
  92. data/lib/shared_tools/tools/eval/ruby_eval_tool.rb +1 -1
  93. data/lib/shared_tools/tools/eval/shell_eval_tool.rb +27 -6
  94. data/lib/shared_tools/tools/eval_tool.rb +1 -1
  95. data/lib/shared_tools/tools/gem_tool.rb +157 -0
  96. data/lib/shared_tools/tools/git/add_tool.rb +73 -0
  97. data/lib/shared_tools/tools/git/blame_tool.rb +77 -0
  98. data/lib/shared_tools/tools/git/branch_tool.rb +55 -0
  99. data/lib/shared_tools/tools/git/checkout_tool.rb +69 -0
  100. data/lib/shared_tools/tools/git/commit_tool.rb +73 -0
  101. data/lib/shared_tools/tools/git/diff_tool.rb +69 -0
  102. data/lib/shared_tools/tools/git/grep_tool.rb +82 -0
  103. data/lib/shared_tools/tools/git/helpers.rb +70 -0
  104. data/lib/shared_tools/tools/git/log_tool.rb +71 -0
  105. data/lib/shared_tools/tools/git/show_tool.rb +66 -0
  106. data/lib/shared_tools/tools/git/status_tool.rb +46 -0
  107. data/lib/shared_tools/tools/git.rb +18 -0
  108. data/lib/shared_tools/tools/git_tool.rb +148 -0
  109. data/lib/shared_tools/tools/glob_tool.rb +75 -0
  110. data/lib/shared_tools/tools/http_helpers.rb +119 -0
  111. data/lib/shared_tools/tools/http_request_tool.rb +91 -0
  112. data/lib/shared_tools/tools/json_query_tool.rb +81 -0
  113. data/lib/shared_tools/tools/lint_tool.rb +93 -0
  114. data/lib/shared_tools/tools/multi_edit_tool.rb +134 -0
  115. data/lib/shared_tools/tools/notification_tool.rb +1 -1
  116. data/lib/shared_tools/tools/parse_ruby_tool.rb +127 -0
  117. data/lib/shared_tools/tools/process_kill_tool.rb +55 -0
  118. data/lib/shared_tools/tools/process_list_tool.rb +41 -0
  119. data/lib/shared_tools/tools/process_output_tool.rb +69 -0
  120. data/lib/shared_tools/tools/process_start_tool.rb +0 -0
  121. data/lib/shared_tools/tools/python_tests_tool.rb +105 -0
  122. data/lib/shared_tools/tools/replace_in_files_tool.rb +166 -0
  123. data/lib/shared_tools/tools/run_tests_tool.rb +108 -0
  124. data/lib/shared_tools/tools/search_codebase_tool.rb +222 -0
  125. data/lib/shared_tools/tools/secure_tool_template.rb +1 -1
  126. data/lib/shared_tools/tools/system_info_tool.rb +1 -1
  127. data/lib/shared_tools/tools/todo_write_tool.rb +81 -0
  128. data/lib/shared_tools/tools/toml_query_tool.rb +81 -0
  129. data/lib/shared_tools/tools/toolchain_helpers.rb +64 -0
  130. data/lib/shared_tools/tools/tree_tool.rb +100 -0
  131. data/lib/shared_tools/tools/weather_tool.rb +12 -2
  132. data/lib/shared_tools/tools/web_fetch_tool.rb +85 -0
  133. data/lib/shared_tools/tools/workflow_manager_tool.rb +1 -1
  134. data/lib/shared_tools/tools/yaml_query_tool.rb +86 -0
  135. data/lib/shared_tools/tree_tool.rb +4 -0
  136. data/lib/shared_tools/url_guard.rb +106 -0
  137. data/lib/shared_tools/utilities.rb +2 -2
  138. data/lib/shared_tools/version.rb +1 -1
  139. data/lib/shared_tools/web_fetch_tool.rb +4 -0
  140. data/lib/shared_tools/yaml_query_tool.rb +4 -0
  141. data/lib/shared_tools.rb +1 -1
  142. metadata +516 -394
  143. data/lib/shared_tools/mcp/streamable_http_patch.rb +0 -42
  144. data/lib/shared_tools/tools/version.rb +0 -7
@@ -0,0 +1,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
- params do
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
- params do
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