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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +211 -7
- data/lib/shared_tools/apply_patch_tool.rb +4 -0
- data/lib/shared_tools/bash_tool.rb +4 -0
- data/lib/shared_tools/bundle_tool.rb +4 -0
- data/lib/shared_tools/command_guard.rb +35 -0
- data/lib/shared_tools/csv_read_tool.rb +4 -0
- data/lib/shared_tools/csv_write_tool.rb +4 -0
- data/lib/shared_tools/data_path.rb +52 -0
- data/lib/shared_tools/diff_tool.rb +4 -0
- data/lib/shared_tools/download_file_tool.rb +4 -0
- data/lib/shared_tools/gem_tool.rb +4 -0
- data/lib/shared_tools/git_tool.rb +5 -0
- data/lib/shared_tools/glob_tool.rb +4 -0
- data/lib/shared_tools/http_request_tool.rb +4 -0
- data/lib/shared_tools/json_query_tool.rb +4 -0
- data/lib/shared_tools/lint_tool.rb +4 -0
- data/lib/shared_tools/mcp/brave_search_client.rb +18 -16
- data/lib/shared_tools/mcp/chart_client.rb +18 -15
- data/lib/shared_tools/mcp/client_support.rb +103 -0
- data/lib/shared_tools/mcp/github_client.rb +23 -19
- data/lib/shared_tools/mcp/hugging_face_client.rb +22 -22
- data/lib/shared_tools/mcp/memory_client.rb +20 -15
- data/lib/shared_tools/mcp/notion_client.rb +21 -19
- data/lib/shared_tools/mcp/playwright_client.rb +19 -15
- data/lib/shared_tools/mcp/sequential_thinking_client.rb +17 -14
- data/lib/shared_tools/mcp/slack_client.rb +30 -25
- data/lib/shared_tools/mcp/tavily_client.rb +21 -21
- data/lib/shared_tools/mcp.rb +32 -41
- data/lib/shared_tools/multi_edit_tool.rb +4 -0
- data/lib/shared_tools/parse_ruby_tool.rb +4 -0
- data/lib/shared_tools/process_kill_tool.rb +4 -0
- data/lib/shared_tools/process_list_tool.rb +4 -0
- data/lib/shared_tools/process_output_tool.rb +4 -0
- data/lib/shared_tools/process_registry.rb +224 -0
- data/lib/shared_tools/process_runner.rb +69 -0
- data/lib/shared_tools/process_start_tool.rb +4 -0
- data/lib/shared_tools/python_tests_tool.rb +4 -0
- data/lib/shared_tools/replace_in_files_tool.rb +4 -0
- data/lib/shared_tools/ruby_outline.rb +207 -0
- data/lib/shared_tools/run_tests_tool.rb +4 -0
- data/lib/shared_tools/text_diff.rb +80 -0
- data/lib/shared_tools/todo_write_tool.rb +4 -0
- data/lib/shared_tools/toml_parser.rb +409 -0
- data/lib/shared_tools/toml_query_tool.rb +4 -0
- data/lib/shared_tools/tools/apply_patch_tool.rb +122 -0
- data/lib/shared_tools/tools/bash_tool.rb +102 -0
- data/lib/shared_tools/tools/browser/click_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/inspect_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/page_inspect_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/visit_tool.rb +1 -1
- data/lib/shared_tools/tools/browser_tool.rb +1 -1
- data/lib/shared_tools/tools/bundle_tool.rb +96 -0
- data/lib/shared_tools/tools/calculator_tool.rb +1 -1
- data/lib/shared_tools/tools/clipboard_tool.rb +1 -1
- data/lib/shared_tools/tools/composite_analysis_tool.rb +1 -1
- data/lib/shared_tools/tools/computer_tool.rb +1 -1
- data/lib/shared_tools/tools/cron_tool.rb +1 -1
- data/lib/shared_tools/tools/csv_read_tool.rb +82 -0
- data/lib/shared_tools/tools/csv_write_tool.rb +67 -0
- data/lib/shared_tools/tools/current_date_time_tool.rb +1 -1
- data/lib/shared_tools/tools/data_science_kit.rb +1 -1
- data/lib/shared_tools/tools/database_query_tool.rb +1 -1
- data/lib/shared_tools/tools/database_tool.rb +1 -1
- data/lib/shared_tools/tools/diff_tool.rb +48 -0
- data/lib/shared_tools/tools/disk/directory_create_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/directory_delete_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/directory_list_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/directory_move_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_create_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_delete_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_move_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_read_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_replace_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_write_tool.rb +1 -1
- data/lib/shared_tools/tools/disk_tool.rb +1 -1
- data/lib/shared_tools/tools/dns_tool.rb +1 -1
- data/lib/shared_tools/tools/doc/docx_reader_tool.rb +1 -1
- data/lib/shared_tools/tools/doc/pdf_reader_tool.rb +1 -1
- data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +1 -1
- data/lib/shared_tools/tools/doc/text_reader_tool.rb +1 -1
- data/lib/shared_tools/tools/doc_tool.rb +1 -1
- data/lib/shared_tools/tools/docker/compose_run_tool.rb +1 -1
- data/lib/shared_tools/tools/download_file_tool.rb +87 -0
- data/lib/shared_tools/tools/enabler.rb +2 -2
- data/lib/shared_tools/tools/error_handling_tool.rb +1 -1
- data/lib/shared_tools/tools/eval/python_eval_tool.rb +1 -1
- data/lib/shared_tools/tools/eval/ruby_eval_tool.rb +1 -1
- data/lib/shared_tools/tools/eval/shell_eval_tool.rb +1 -1
- data/lib/shared_tools/tools/eval_tool.rb +1 -1
- data/lib/shared_tools/tools/gem_tool.rb +157 -0
- data/lib/shared_tools/tools/git/add_tool.rb +73 -0
- data/lib/shared_tools/tools/git/blame_tool.rb +77 -0
- data/lib/shared_tools/tools/git/branch_tool.rb +55 -0
- data/lib/shared_tools/tools/git/checkout_tool.rb +69 -0
- data/lib/shared_tools/tools/git/commit_tool.rb +73 -0
- data/lib/shared_tools/tools/git/diff_tool.rb +69 -0
- data/lib/shared_tools/tools/git/grep_tool.rb +82 -0
- data/lib/shared_tools/tools/git/helpers.rb +70 -0
- data/lib/shared_tools/tools/git/log_tool.rb +71 -0
- data/lib/shared_tools/tools/git/show_tool.rb +66 -0
- data/lib/shared_tools/tools/git/status_tool.rb +46 -0
- data/lib/shared_tools/tools/git.rb +18 -0
- data/lib/shared_tools/tools/git_tool.rb +148 -0
- data/lib/shared_tools/tools/glob_tool.rb +75 -0
- data/lib/shared_tools/tools/http_helpers.rb +119 -0
- data/lib/shared_tools/tools/http_request_tool.rb +91 -0
- data/lib/shared_tools/tools/json_query_tool.rb +81 -0
- data/lib/shared_tools/tools/lint_tool.rb +93 -0
- data/lib/shared_tools/tools/multi_edit_tool.rb +134 -0
- data/lib/shared_tools/tools/notification_tool.rb +1 -1
- data/lib/shared_tools/tools/parse_ruby_tool.rb +127 -0
- data/lib/shared_tools/tools/process_kill_tool.rb +55 -0
- data/lib/shared_tools/tools/process_list_tool.rb +41 -0
- data/lib/shared_tools/tools/process_output_tool.rb +69 -0
- data/lib/shared_tools/tools/process_start_tool.rb +0 -0
- data/lib/shared_tools/tools/python_tests_tool.rb +105 -0
- data/lib/shared_tools/tools/replace_in_files_tool.rb +166 -0
- data/lib/shared_tools/tools/run_tests_tool.rb +108 -0
- data/lib/shared_tools/tools/search_codebase_tool.rb +178 -47
- data/lib/shared_tools/tools/secure_tool_template.rb +1 -1
- data/lib/shared_tools/tools/system_info_tool.rb +1 -1
- data/lib/shared_tools/tools/todo_write_tool.rb +81 -0
- data/lib/shared_tools/tools/toml_query_tool.rb +81 -0
- data/lib/shared_tools/tools/toolchain_helpers.rb +64 -0
- data/lib/shared_tools/tools/tree_tool.rb +100 -0
- data/lib/shared_tools/tools/weather_tool.rb +1 -1
- data/lib/shared_tools/tools/web_fetch_tool.rb +85 -0
- data/lib/shared_tools/tools/workflow_manager_tool.rb +1 -1
- data/lib/shared_tools/tools/yaml_query_tool.rb +86 -0
- data/lib/shared_tools/tree_tool.rb +4 -0
- data/lib/shared_tools/url_guard.rb +106 -0
- data/lib/shared_tools/utilities.rb +2 -2
- data/lib/shared_tools/version.rb +1 -1
- data/lib/shared_tools/web_fetch_tool.rb +4 -0
- data/lib/shared_tools/yaml_query_tool.rb +4 -0
- data/lib/shared_tools.rb +1 -1
- metadata +516 -409
- data/lib/shared_tools/mcp/streamable_http_patch.rb +0 -42
- 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
|
|
Binary file
|
|
@@ -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
|