shared_tools 0.4.4 → 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 +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 +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 +1 -1
  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 +1 -1
  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 +1 -1
  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 +178 -47
  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 +1 -1
  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 -409
  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,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "../../shared_tools"
5
+ require_relative "../ruby_outline"
6
+
7
+ module SharedTools
8
+ module Tools
9
+ # Produces a structural outline of a Ruby file (classes, modules,
10
+ # methods, constants) with line numbers, or finds where a name is
11
+ # defined. In-process via Ripper/Prism — it parses, never executes, the
12
+ # code. Read-only.
13
+ #
14
+ # @example
15
+ # tool = SharedTools::Tools::ParseRubyTool.new
16
+ # tool.execute(path: "./lib/foo.rb")
17
+ # tool.execute(path: "./lib/foo.rb", query: "initialize")
18
+ # tool.execute(path: "./lib/foo.rb", kind: "method")
19
+ class ParseRubyTool < ::RubyLLM::Tool
20
+ KINDS = %w[class module method constant].freeze
21
+ MAX_BYTES = 5 * 1024 * 1024
22
+
23
+ def self.name = 'parse_ruby'
24
+
25
+ description "Outline a Ruby file's structure (classes, modules, methods, constants with line " \
26
+ "numbers), or locate definitions. Give a query to find definitions whose name " \
27
+ "matches, and/or a kind to filter. Parses in-process; does not run the code."
28
+
29
+ parameters do
30
+ string :path, description: "Ruby file to outline."
31
+ string :query, description: "Only show definitions whose (qualified) name matches this, case-insensitive. Optional.", required: false
32
+ string :kind, description: "Filter to a kind: class, module, method, or constant. Optional.", required: false
33
+ end
34
+
35
+ # @param logger [Logger] optional logger
36
+ def initialize(logger: nil)
37
+ @logger = logger || RubyLLM.logger
38
+ end
39
+
40
+ # @param path [String]
41
+ # @param query [String, nil]
42
+ # @param kind [String, nil]
43
+ #
44
+ # @return [String, Hash]
45
+ def execute(path:, query: nil, kind: nil)
46
+ @logger.info("#{self.class.name}#execute path=#{path.inspect} query=#{query.inspect} kind=#{kind.inspect}")
47
+
48
+ k = kind.to_s.strip.downcase
49
+ k = nil if k.empty?
50
+ return { error: "unknown kind: #{k} (use #{KINDS.join(', ')})" } if k && !KINDS.include?(k)
51
+
52
+ return { error: "file not found: #{path}" } unless File.exist?(path)
53
+ return { error: "not a file: #{path}" } unless File.file?(path)
54
+ return { error: "file too large (> #{MAX_BYTES} bytes)" } if File.size(path) > MAX_BYTES
55
+
56
+ entries = SharedTools::RubyOutline.extract(File.read(path).scrub)
57
+ return "no definitions found in #{path}" if entries.empty?
58
+
59
+ render(entries, path, query, k)
60
+ rescue SharedTools::RubyOutline::ParseError => e
61
+ @logger.error("#{self.class.name}: #{e.message} in #{path}")
62
+ { error: "#{e.message} in #{path}" }
63
+ rescue => e
64
+ @logger.error("#{self.class.name} failed: #{e.message}")
65
+ { error: e.message }
66
+ end
67
+
68
+ private
69
+
70
+ def render(entries, path, query, kind)
71
+ if query || kind
72
+ flat_list(entries, path, query, kind)
73
+ else
74
+ "Outline of #{path}:\n#{tree(entries)}"
75
+ end
76
+ end
77
+
78
+ def tree(entries)
79
+ entries.map { |e| "#{' ' * e.depth}#{label(e)} (L#{e.line})" }.join("\n")
80
+ end
81
+
82
+ def flat_list(entries, path, query, kind)
83
+ pairs = qualify(entries)
84
+ pairs = pairs.select { |e, _| e.kind.to_s == kind } if kind
85
+ if query
86
+ q = query.downcase
87
+ pairs = pairs.select { |e, qn| qn.downcase.include?(q) || e.name.downcase.include?(q) }
88
+ end
89
+ return "no matching definitions in #{path}" if pairs.empty?
90
+
91
+ desc = ["definitions", ("matching #{query.inspect}" if query), ("of kind #{kind}" if kind)].compact.join(" ")
92
+ header = "#{pairs.size} #{desc} in #{path}:"
93
+ "#{header}\n#{pairs.map { |e, qn| "#{qn} (L#{e.line}) [#{e.kind}]" }.join("\n")}"
94
+ end
95
+
96
+ # Pair each entry with its fully-qualified name using the depth stack.
97
+ def qualify(entries)
98
+ namespace = []
99
+ entries.map do |entry|
100
+ namespace = namespace.first(entry.depth)
101
+ ns = namespace.compact
102
+ qualified =
103
+ case entry.kind
104
+ when :class, :module, :singleton_class
105
+ full = (ns + [entry.name]).join("::")
106
+ namespace[entry.depth] = entry.name
107
+ full
108
+ when :method
109
+ ns.empty? ? entry.name : "#{ns.join('::')}##{entry.name}"
110
+ else # constant
111
+ ns.empty? ? entry.name : "#{ns.join('::')}::#{entry.name}"
112
+ end
113
+ [entry, qualified]
114
+ end
115
+ end
116
+
117
+ def label(entry)
118
+ case entry.kind
119
+ when :class, :singleton_class then "class #{entry.name}"
120
+ when :module then "module #{entry.name}"
121
+ when :method then "def #{entry.name}"
122
+ else entry.name
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../shared_tools"
4
+ require_relative "../process_registry"
5
+
6
+ module SharedTools
7
+ module Tools
8
+ # Stops a background process: SIGTERM to its process group, escalating
9
+ # to SIGKILL if it doesn't exit, then returns any final output. Always
10
+ # available, with no authorization prompt, so a runaway process can
11
+ # always be stopped. Removes the process from the registry.
12
+ #
13
+ # @example
14
+ # tool = SharedTools::Tools::ProcessKillTool.new
15
+ # tool.execute(id: "proc_1")
16
+ class ProcessKillTool < ::RubyLLM::Tool
17
+ def self.name = 'process_kill'
18
+
19
+ description "Stop a background process (started with process_start) and return any final " \
20
+ "output. Terminates the whole process group, escalating to SIGKILL if needed."
21
+
22
+ parameters do
23
+ string :id, description: "The process id to kill (e.g. 'proc_1')."
24
+ end
25
+
26
+ # @param logger [Logger] optional logger
27
+ def initialize(logger: nil)
28
+ @logger = logger || RubyLLM.logger
29
+ end
30
+
31
+ # @param id [String]
32
+ #
33
+ # @return [String, Hash]
34
+ def execute(id:)
35
+ @logger.info("#{self.class.name}#execute id=#{id.inspect}")
36
+
37
+ proc = SharedTools::ProcessRegistry.get(id)
38
+ return { error: "no such process: #{id}" } unless proc
39
+
40
+ was_running = proc.running?
41
+ proc.kill
42
+ final = proc.read_new
43
+ SharedTools::ProcessRegistry.delete(id)
44
+
45
+ body = +"#{id} #{was_running ? 'terminated' : 'was already exited'}"
46
+ body << " (exit #{proc.exit_code})" if proc.exit_code
47
+ unless final[:out].empty? && final[:err].empty?
48
+ body << "\n--- final stdout ---\n#{final[:out]}" unless final[:out].empty?
49
+ body << "\n--- final stderr ---\n#{final[:err]}" unless final[:err].empty?
50
+ end
51
+ body
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../shared_tools"
4
+ require_relative "../process_registry"
5
+
6
+ module SharedTools
7
+ module Tools
8
+ # Lists the background processes started this run (with ProcessStartTool):
9
+ # id, status, pid, age, and command — so a caller can rediscover ids and
10
+ # see what's still running. Read-only.
11
+ #
12
+ # @example
13
+ # tool = SharedTools::Tools::ProcessListTool.new
14
+ # tool.execute
15
+ class ProcessListTool < ::RubyLLM::Tool
16
+ def self.name = 'process_list'
17
+
18
+ description "List background processes (started with process_start): id, status, pid, age, and " \
19
+ "command. Read-only."
20
+
21
+ # @param logger [Logger] optional logger
22
+ def initialize(logger: nil)
23
+ @logger = logger || RubyLLM.logger
24
+ end
25
+
26
+ # @return [String]
27
+ def execute
28
+ @logger.info("#{self.class.name}#execute")
29
+
30
+ procs = SharedTools::ProcessRegistry.all
31
+ return "no background processes" if procs.empty?
32
+
33
+ lines = procs.map do |proc|
34
+ status = proc.status == :exited ? "exited(#{proc.exit_code})" : "running"
35
+ "#{proc.id} #{status.ljust(12)} pid=#{proc.pid} age=#{proc.age.round}s #{proc.name} #{proc.argv.inspect}"
36
+ end
37
+ (["#{procs.size} process#{procs.size == 1 ? '' : 'es'}:"] + lines).join("\n")
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../shared_tools"
4
+ require_relative "../process_registry"
5
+
6
+ module SharedTools
7
+ module Tools
8
+ # Returns the output a background process has produced since the last
9
+ # read, plus its current status (running, or exited with a code). Reads
10
+ # are incremental, so polling in a loop streams the process's output
11
+ # without repeats. Read-only.
12
+ #
13
+ # @example
14
+ # tool = SharedTools::Tools::ProcessOutputTool.new
15
+ # tool.execute(id: "proc_1")
16
+ class ProcessOutputTool < ::RubyLLM::Tool
17
+ def self.name = 'process_output'
18
+
19
+ description "Read new stdout/stderr from a background process (started with process_start) " \
20
+ "since the last read, along with its status and exit code if it has finished. Poll " \
21
+ "this to follow a process's output."
22
+
23
+ parameters do
24
+ string :id, description: "The process id returned by process_start (e.g. 'proc_1')."
25
+ end
26
+
27
+ # @param logger [Logger] optional logger
28
+ def initialize(logger: nil)
29
+ @logger = logger || RubyLLM.logger
30
+ end
31
+
32
+ # @param id [String]
33
+ #
34
+ # @return [String, Hash]
35
+ def execute(id:)
36
+ @logger.info("#{self.class.name}#execute id=#{id.inspect}")
37
+
38
+ proc = SharedTools::ProcessRegistry.get(id)
39
+ return { error: "no such process: #{id}" } unless proc
40
+
41
+ data = proc.read_new
42
+ format_output(proc, data)
43
+ end
44
+
45
+ private
46
+
47
+ def format_output(proc, data)
48
+ body = +"#{proc.id} (#{proc.name}): #{proc.status}"
49
+ body << " (exit #{proc.exit_code})" if proc.status == :exited && proc.exit_code
50
+ body << "\n"
51
+
52
+ if data[:out].empty? && data[:err].empty?
53
+ body << "(no new output)"
54
+ return body
55
+ end
56
+
57
+ unless data[:out].empty?
58
+ body << "\n--- stdout#{data[:out_dropped] ? ' (earlier output dropped)' : ''} ---\n"
59
+ body << data[:out]
60
+ end
61
+ unless data[:err].empty?
62
+ body << "\n--- stderr#{data[:err_dropped] ? ' (earlier output dropped)' : ''} ---\n"
63
+ body << data[:err]
64
+ end
65
+ body
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,105 @@
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 Python project's tests (pytest or unittest) from root and
9
+ # returns the output with a pass/fail headline. A failing suite is a
10
+ # normal result, not a tool error. Runs in the project environment, so a
11
+ # virtualenv/installed deps resolve as they would in a shell. Requires
12
+ # user authorization (see SharedTools.execute?) since it executes an
13
+ # external command.
14
+ #
15
+ # @example
16
+ # tool = SharedTools::Tools::PythonTestsTool.new(root: "./my-project")
17
+ # tool.execute
18
+ # tool.execute(path: "tests/test_models.py", framework: "unittest")
19
+ class PythonTestsTool < ::RubyLLM::Tool
20
+ include ToolchainHelpers
21
+
22
+ FRAMEWORKS = %w[pytest unittest].freeze
23
+ DEFAULT_TIMEOUT = 180
24
+
25
+ def self.name = 'python_tests'
26
+
27
+ description "Run a Python project's test suite from root and report results. Uses pytest by " \
28
+ "default, or unittest (python -m unittest discover) when framework is unittest. " \
29
+ "Optionally scope to a path."
30
+
31
+ parameters do
32
+ string :path, description: "Limit the run to this test file or directory, relative to root. Optional.", required: false
33
+ string :framework, description: "pytest (default) or unittest.", required: false
34
+ end
35
+
36
+ # @param root [String] optional, defaults to the current directory
37
+ # @param logger [Logger] optional logger
38
+ def initialize(root: nil, logger: nil)
39
+ @root = root || Dir.pwd
40
+ @logger = logger || RubyLLM.logger
41
+ end
42
+
43
+ # @param path [String, nil]
44
+ # @param framework [String]
45
+ #
46
+ # @return [String, Hash]
47
+ def execute(path: nil, framework: "pytest")
48
+ @logger.info("#{self.class.name}#execute path=#{path.inspect} framework=#{framework}")
49
+
50
+ fw = framework.to_s.strip.downcase
51
+ fw = "pytest" if fw.empty?
52
+ return { error: "unknown framework: #{fw} (use #{FRAMEWORKS.join(', ')})" } unless FRAMEWORKS.include?(fw)
53
+
54
+ rel = jail_relative(path)
55
+
56
+ allowed = SharedTools.execute?(tool: self.class.to_s, stuff: "Run #{fw} tests#{rel ? " (#{rel})" : ''}")
57
+ unless allowed
58
+ @logger.warn("User declined to run tests")
59
+ return { error: "User declined to run tests" }
60
+ end
61
+
62
+ out, err, status = run_in_project(args_for(fw, rel), use_bundle: false, timeout: DEFAULT_TIMEOUT)
63
+ toolchain_output(out, err, status,
64
+ pass_label: summarize(out, err, "TESTS PASSED"),
65
+ fail_label: summarize(out, err, "TESTS FAILED"),
66
+ timeout: DEFAULT_TIMEOUT)
67
+ rescue SecurityError => e
68
+ @logger.error("#{self.class.name} path denied: #{e.message}")
69
+ { error: e.message }
70
+ rescue CommandMissing => e
71
+ @logger.error("#{self.class.name}: #{e.message} is not available")
72
+ { error: "#{e.message} is not available (is it installed in the project env?)" }
73
+ end
74
+
75
+ private
76
+
77
+ def args_for(framework, rel)
78
+ if framework == "unittest"
79
+ args = ["python3", "-m", "unittest", "discover"]
80
+ args += ["-s", rel] if rel
81
+ else
82
+ args = ["pytest"]
83
+ args << rel if rel
84
+ end
85
+ args
86
+ end
87
+
88
+ # Surface pytest's or unittest's summary in the headline.
89
+ def summarize(out, err, label)
90
+ text = "#{out}\n#{err}"
91
+ counts = %w[passed failed error skipped].filter_map do |word|
92
+ m = text.match(/(\d+) #{word}/)
93
+ "#{m[1]} #{word}" if m
94
+ end
95
+ return "#{label} (#{counts.join(', ')})" unless counts.empty?
96
+
97
+ if (m = text.match(/Ran (\d+) tests?.*?(OK|FAILED)/m))
98
+ return "#{label} (ran #{m[1]}, #{m[2]})"
99
+ end
100
+
101
+ label
102
+ end
103
+ end
104
+ end
105
+ end
@@ -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