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.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/README.md +211 -7
  4. data/lib/shared_tools/apply_patch_tool.rb +4 -0
  5. data/lib/shared_tools/bash_tool.rb +4 -0
  6. data/lib/shared_tools/bundle_tool.rb +4 -0
  7. data/lib/shared_tools/command_guard.rb +35 -0
  8. data/lib/shared_tools/csv_read_tool.rb +4 -0
  9. data/lib/shared_tools/csv_write_tool.rb +4 -0
  10. data/lib/shared_tools/data_path.rb +52 -0
  11. data/lib/shared_tools/diff_tool.rb +4 -0
  12. data/lib/shared_tools/download_file_tool.rb +4 -0
  13. data/lib/shared_tools/gem_tool.rb +4 -0
  14. data/lib/shared_tools/git_tool.rb +5 -0
  15. data/lib/shared_tools/glob_tool.rb +4 -0
  16. data/lib/shared_tools/http_request_tool.rb +4 -0
  17. data/lib/shared_tools/json_query_tool.rb +4 -0
  18. data/lib/shared_tools/lint_tool.rb +4 -0
  19. data/lib/shared_tools/mcp/brave_search_client.rb +18 -16
  20. data/lib/shared_tools/mcp/chart_client.rb +18 -15
  21. data/lib/shared_tools/mcp/client_support.rb +103 -0
  22. data/lib/shared_tools/mcp/github_client.rb +23 -19
  23. data/lib/shared_tools/mcp/hugging_face_client.rb +22 -22
  24. data/lib/shared_tools/mcp/memory_client.rb +20 -15
  25. data/lib/shared_tools/mcp/notion_client.rb +21 -19
  26. data/lib/shared_tools/mcp/playwright_client.rb +19 -15
  27. data/lib/shared_tools/mcp/sequential_thinking_client.rb +17 -14
  28. data/lib/shared_tools/mcp/slack_client.rb +30 -25
  29. data/lib/shared_tools/mcp/tavily_client.rb +21 -21
  30. data/lib/shared_tools/mcp.rb +32 -41
  31. data/lib/shared_tools/multi_edit_tool.rb +4 -0
  32. data/lib/shared_tools/parse_ruby_tool.rb +4 -0
  33. data/lib/shared_tools/process_kill_tool.rb +4 -0
  34. data/lib/shared_tools/process_list_tool.rb +4 -0
  35. data/lib/shared_tools/process_output_tool.rb +4 -0
  36. data/lib/shared_tools/process_registry.rb +224 -0
  37. data/lib/shared_tools/process_runner.rb +69 -0
  38. data/lib/shared_tools/process_start_tool.rb +4 -0
  39. data/lib/shared_tools/python_tests_tool.rb +4 -0
  40. data/lib/shared_tools/replace_in_files_tool.rb +4 -0
  41. data/lib/shared_tools/ruby_outline.rb +207 -0
  42. data/lib/shared_tools/run_tests_tool.rb +4 -0
  43. data/lib/shared_tools/text_diff.rb +80 -0
  44. data/lib/shared_tools/todo_write_tool.rb +4 -0
  45. data/lib/shared_tools/toml_parser.rb +409 -0
  46. data/lib/shared_tools/toml_query_tool.rb +4 -0
  47. data/lib/shared_tools/tools/apply_patch_tool.rb +122 -0
  48. data/lib/shared_tools/tools/bash_tool.rb +102 -0
  49. data/lib/shared_tools/tools/browser/click_tool.rb +2 -2
  50. data/lib/shared_tools/tools/browser/inspect_tool.rb +2 -2
  51. data/lib/shared_tools/tools/browser/page_inspect_tool.rb +2 -2
  52. data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +2 -2
  53. data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +2 -2
  54. data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +2 -2
  55. data/lib/shared_tools/tools/browser/visit_tool.rb +2 -2
  56. data/lib/shared_tools/tools/browser_tool.rb +2 -2
  57. data/lib/shared_tools/tools/bundle_tool.rb +96 -0
  58. data/lib/shared_tools/tools/calculator_tool.rb +2 -2
  59. data/lib/shared_tools/tools/clipboard_tool.rb +2 -2
  60. data/lib/shared_tools/tools/composite_analysis_tool.rb +2 -2
  61. data/lib/shared_tools/tools/computer_tool.rb +2 -2
  62. data/lib/shared_tools/tools/cron_tool.rb +2 -2
  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 +3 -3
  66. data/lib/shared_tools/tools/data_science_kit.rb +2 -2
  67. data/lib/shared_tools/tools/database_query_tool.rb +2 -2
  68. data/lib/shared_tools/tools/database_tool.rb +2 -2
  69. data/lib/shared_tools/tools/diff_tool.rb +48 -0
  70. data/lib/shared_tools/tools/disk/directory_create_tool.rb +2 -2
  71. data/lib/shared_tools/tools/disk/directory_delete_tool.rb +2 -2
  72. data/lib/shared_tools/tools/disk/directory_list_tool.rb +2 -2
  73. data/lib/shared_tools/tools/disk/directory_move_tool.rb +2 -2
  74. data/lib/shared_tools/tools/disk/file_create_tool.rb +2 -2
  75. data/lib/shared_tools/tools/disk/file_delete_tool.rb +2 -2
  76. data/lib/shared_tools/tools/disk/file_move_tool.rb +2 -2
  77. data/lib/shared_tools/tools/disk/file_read_tool.rb +2 -2
  78. data/lib/shared_tools/tools/disk/file_replace_tool.rb +2 -2
  79. data/lib/shared_tools/tools/disk/file_write_tool.rb +2 -2
  80. data/lib/shared_tools/tools/disk_tool.rb +2 -2
  81. data/lib/shared_tools/tools/dns_tool.rb +2 -2
  82. data/lib/shared_tools/tools/doc/docx_reader_tool.rb +2 -2
  83. data/lib/shared_tools/tools/doc/pdf_reader_tool.rb +2 -2
  84. data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +2 -2
  85. data/lib/shared_tools/tools/doc/text_reader_tool.rb +2 -2
  86. data/lib/shared_tools/tools/doc_tool.rb +2 -2
  87. data/lib/shared_tools/tools/docker/compose_run_tool.rb +2 -2
  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 +2 -2
  91. data/lib/shared_tools/tools/eval/python_eval_tool.rb +2 -2
  92. data/lib/shared_tools/tools/eval/ruby_eval_tool.rb +2 -2
  93. data/lib/shared_tools/tools/eval/shell_eval_tool.rb +2 -2
  94. data/lib/shared_tools/tools/eval_tool.rb +2 -2
  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 +2 -2
  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 +182 -51
  125. data/lib/shared_tools/tools/secure_tool_template.rb +2 -2
  126. data/lib/shared_tools/tools/system_info_tool.rb +2 -2
  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 +2 -2
  132. data/lib/shared_tools/tools/web_fetch_tool.rb +85 -0
  133. data/lib/shared_tools/tools/workflow_manager_tool.rb +2 -2
  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 +532 -409
  143. data/lib/shared_tools/mcp/streamable_http_patch.rb +0 -42
  144. data/lib/shared_tools/tools/version.rb +0 -7
@@ -1,56 +1,85 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
3
+ require "find"
4
+ require "pathname"
4
5
 
5
6
  module SharedTools
6
7
  module Tools
7
- # Search for a term across files using ripgrep (rg) with grep as fallback.
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(term: "def execute")
13
- # tool.execute(term: "RubyLLM", extension: "rb", max_results: 20)
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
- def self.name = 'search_codebase'
18
-
19
- description "Search for a term or pattern across files. Uses ripgrep (rg) when available, falls back to grep. Returns matching lines with file path and line number."
20
-
21
- params do
22
- string :term, description: "The search term or regex pattern to find"
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 :extension, description: "File extension to restrict the search to, without the dot (e.g. 'rb', 'js')", required: false
25
- integer :max_results, description: "Maximum number of matching lines to return (default: 50, max: 500)", 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
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(term:, path: ".", extension: nil, max_results: 50)
33
- @logger.info("SearchCodebaseTool#execute term=#{term.inspect} path=#{path} extension=#{extension.inspect}")
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 term cannot be empty" } if term.strip.empty?
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
- stdout, _stderr, _status = run_search(term: term, path: search_path, extension: extension)
43
-
44
- all_lines = stdout.lines.map(&:chomp).reject(&:empty?)
45
- matches = all_lines.first(max_results)
46
- truncated = all_lines.size > max_results
47
-
48
- {
49
- matches: matches,
50
- count: matches.size,
51
- truncated: truncated,
52
- tool: ripgrep_available? ? "rg" : "grep"
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 run_search(term:, path:, extension:)
62
- if ripgrep_available?
63
- run_ripgrep(term: term, path: path, extension: extension)
64
- else
65
- run_grep(term: term, path: path, extension: extension)
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
- def run_ripgrep(term:, path:, extension:)
70
- args = ["rg", "--no-heading", "-n"]
71
- args += ["-g", "*.#{extension}"] if extension
72
- args << term
73
- args << path
74
- Open3.capture3(*args)
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 run_grep(term:, path:, extension:)
78
- args = ["grep", "-r", "-n"]
79
- args += ["--include=*.#{extension}"] if extension
80
- args << term
81
- args << path
82
- Open3.capture3(*args)
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 ripgrep_available?
86
- return @ripgrep_available unless @ripgrep_available.nil?
87
- @ripgrep_available = system("which", "rg", out: File::NULL, err: File::NULL)
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.name = 'secure_tool_template'
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
- 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:
@@ -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.name = 'system_info_tool'
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
- 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.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.name = 'weather_tool'
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
- params do
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').