shared_tools 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/README.md +211 -7
- data/lib/shared_tools/apply_patch_tool.rb +4 -0
- data/lib/shared_tools/bash_tool.rb +4 -0
- data/lib/shared_tools/bundle_tool.rb +4 -0
- data/lib/shared_tools/command_guard.rb +35 -0
- data/lib/shared_tools/csv_read_tool.rb +4 -0
- data/lib/shared_tools/csv_write_tool.rb +4 -0
- data/lib/shared_tools/data_path.rb +52 -0
- data/lib/shared_tools/diff_tool.rb +4 -0
- data/lib/shared_tools/download_file_tool.rb +4 -0
- data/lib/shared_tools/gem_tool.rb +4 -0
- data/lib/shared_tools/git_tool.rb +5 -0
- data/lib/shared_tools/glob_tool.rb +4 -0
- data/lib/shared_tools/http_request_tool.rb +4 -0
- data/lib/shared_tools/json_query_tool.rb +4 -0
- data/lib/shared_tools/lint_tool.rb +4 -0
- data/lib/shared_tools/mcp/brave_search_client.rb +18 -16
- data/lib/shared_tools/mcp/chart_client.rb +18 -15
- data/lib/shared_tools/mcp/client_support.rb +103 -0
- data/lib/shared_tools/mcp/github_client.rb +23 -19
- data/lib/shared_tools/mcp/hugging_face_client.rb +22 -22
- data/lib/shared_tools/mcp/memory_client.rb +20 -15
- data/lib/shared_tools/mcp/notion_client.rb +21 -19
- data/lib/shared_tools/mcp/playwright_client.rb +19 -15
- data/lib/shared_tools/mcp/sequential_thinking_client.rb +17 -14
- data/lib/shared_tools/mcp/slack_client.rb +30 -25
- data/lib/shared_tools/mcp/tavily_client.rb +21 -21
- data/lib/shared_tools/mcp.rb +32 -41
- data/lib/shared_tools/multi_edit_tool.rb +4 -0
- data/lib/shared_tools/parse_ruby_tool.rb +4 -0
- data/lib/shared_tools/process_kill_tool.rb +4 -0
- data/lib/shared_tools/process_list_tool.rb +4 -0
- data/lib/shared_tools/process_output_tool.rb +4 -0
- data/lib/shared_tools/process_registry.rb +224 -0
- data/lib/shared_tools/process_runner.rb +69 -0
- data/lib/shared_tools/process_start_tool.rb +4 -0
- data/lib/shared_tools/python_tests_tool.rb +4 -0
- data/lib/shared_tools/replace_in_files_tool.rb +4 -0
- data/lib/shared_tools/ruby_outline.rb +207 -0
- data/lib/shared_tools/run_tests_tool.rb +4 -0
- data/lib/shared_tools/text_diff.rb +80 -0
- data/lib/shared_tools/todo_write_tool.rb +4 -0
- data/lib/shared_tools/toml_parser.rb +409 -0
- data/lib/shared_tools/toml_query_tool.rb +4 -0
- data/lib/shared_tools/tools/apply_patch_tool.rb +122 -0
- data/lib/shared_tools/tools/bash_tool.rb +102 -0
- data/lib/shared_tools/tools/browser/click_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/inspect_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/page_inspect_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/visit_tool.rb +1 -1
- data/lib/shared_tools/tools/browser_tool.rb +1 -1
- data/lib/shared_tools/tools/bundle_tool.rb +96 -0
- data/lib/shared_tools/tools/calculator_tool.rb +12 -2
- data/lib/shared_tools/tools/clipboard_tool.rb +1 -1
- data/lib/shared_tools/tools/composite_analysis_tool.rb +1 -1
- data/lib/shared_tools/tools/computer_tool.rb +1 -1
- data/lib/shared_tools/tools/cron_tool.rb +1 -1
- data/lib/shared_tools/tools/csv_read_tool.rb +82 -0
- data/lib/shared_tools/tools/csv_write_tool.rb +67 -0
- data/lib/shared_tools/tools/current_date_time_tool.rb +1 -1
- data/lib/shared_tools/tools/data_science_kit.rb +1 -1
- data/lib/shared_tools/tools/database_query_tool.rb +12 -2
- data/lib/shared_tools/tools/database_tool.rb +1 -1
- data/lib/shared_tools/tools/diff_tool.rb +48 -0
- data/lib/shared_tools/tools/disk/directory_create_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/directory_delete_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/directory_list_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/directory_move_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_create_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_delete_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_move_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_read_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_replace_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_write_tool.rb +1 -1
- data/lib/shared_tools/tools/disk_tool.rb +1 -1
- data/lib/shared_tools/tools/dns_tool.rb +1 -1
- data/lib/shared_tools/tools/doc/docx_reader_tool.rb +1 -1
- data/lib/shared_tools/tools/doc/pdf_reader_tool.rb +1 -1
- data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +1 -1
- data/lib/shared_tools/tools/doc/text_reader_tool.rb +1 -1
- data/lib/shared_tools/tools/doc_tool.rb +1 -1
- data/lib/shared_tools/tools/docker/compose_run_tool.rb +1 -1
- data/lib/shared_tools/tools/download_file_tool.rb +87 -0
- data/lib/shared_tools/tools/enabler.rb +2 -2
- data/lib/shared_tools/tools/error_handling_tool.rb +1 -1
- data/lib/shared_tools/tools/eval/python_eval_tool.rb +1 -1
- data/lib/shared_tools/tools/eval/ruby_eval_tool.rb +1 -1
- data/lib/shared_tools/tools/eval/shell_eval_tool.rb +27 -6
- data/lib/shared_tools/tools/eval_tool.rb +1 -1
- data/lib/shared_tools/tools/gem_tool.rb +157 -0
- data/lib/shared_tools/tools/git/add_tool.rb +73 -0
- data/lib/shared_tools/tools/git/blame_tool.rb +77 -0
- data/lib/shared_tools/tools/git/branch_tool.rb +55 -0
- data/lib/shared_tools/tools/git/checkout_tool.rb +69 -0
- data/lib/shared_tools/tools/git/commit_tool.rb +73 -0
- data/lib/shared_tools/tools/git/diff_tool.rb +69 -0
- data/lib/shared_tools/tools/git/grep_tool.rb +82 -0
- data/lib/shared_tools/tools/git/helpers.rb +70 -0
- data/lib/shared_tools/tools/git/log_tool.rb +71 -0
- data/lib/shared_tools/tools/git/show_tool.rb +66 -0
- data/lib/shared_tools/tools/git/status_tool.rb +46 -0
- data/lib/shared_tools/tools/git.rb +18 -0
- data/lib/shared_tools/tools/git_tool.rb +148 -0
- data/lib/shared_tools/tools/glob_tool.rb +75 -0
- data/lib/shared_tools/tools/http_helpers.rb +119 -0
- data/lib/shared_tools/tools/http_request_tool.rb +91 -0
- data/lib/shared_tools/tools/json_query_tool.rb +81 -0
- data/lib/shared_tools/tools/lint_tool.rb +93 -0
- data/lib/shared_tools/tools/multi_edit_tool.rb +134 -0
- data/lib/shared_tools/tools/notification_tool.rb +1 -1
- data/lib/shared_tools/tools/parse_ruby_tool.rb +127 -0
- data/lib/shared_tools/tools/process_kill_tool.rb +55 -0
- data/lib/shared_tools/tools/process_list_tool.rb +41 -0
- data/lib/shared_tools/tools/process_output_tool.rb +69 -0
- data/lib/shared_tools/tools/process_start_tool.rb +0 -0
- data/lib/shared_tools/tools/python_tests_tool.rb +105 -0
- data/lib/shared_tools/tools/replace_in_files_tool.rb +166 -0
- data/lib/shared_tools/tools/run_tests_tool.rb +108 -0
- data/lib/shared_tools/tools/search_codebase_tool.rb +222 -0
- data/lib/shared_tools/tools/secure_tool_template.rb +1 -1
- data/lib/shared_tools/tools/system_info_tool.rb +1 -1
- data/lib/shared_tools/tools/todo_write_tool.rb +81 -0
- data/lib/shared_tools/tools/toml_query_tool.rb +81 -0
- data/lib/shared_tools/tools/toolchain_helpers.rb +64 -0
- data/lib/shared_tools/tools/tree_tool.rb +100 -0
- data/lib/shared_tools/tools/weather_tool.rb +12 -2
- data/lib/shared_tools/tools/web_fetch_tool.rb +85 -0
- data/lib/shared_tools/tools/workflow_manager_tool.rb +1 -1
- data/lib/shared_tools/tools/yaml_query_tool.rb +86 -0
- data/lib/shared_tools/tree_tool.rb +4 -0
- data/lib/shared_tools/url_guard.rb +106 -0
- data/lib/shared_tools/utilities.rb +2 -2
- data/lib/shared_tools/version.rb +1 -1
- data/lib/shared_tools/web_fetch_tool.rb +4 -0
- data/lib/shared_tools/yaml_query_tool.rb +4 -0
- data/lib/shared_tools.rb +1 -1
- metadata +516 -394
- data/lib/shared_tools/mcp/streamable_http_patch.rb +0 -42
- data/lib/shared_tools/tools/version.rb +0 -7
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require_relative '../../shared_tools'
|
|
5
|
+
|
|
6
|
+
module SharedTools
|
|
7
|
+
module Tools
|
|
8
|
+
# Applies several string replacements to one file in a single, atomic
|
|
9
|
+
# operation. Edits are applied in order (a later edit sees the result of
|
|
10
|
+
# earlier ones); each edit's old_string must match exactly once unless
|
|
11
|
+
# replace_all is set. If any edit can't be applied, nothing is written and
|
|
12
|
+
# the failing edit is reported — so the file is never left half-edited.
|
|
13
|
+
# Mutating — requires user authorization (see SharedTools.execute?).
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# tool = SharedTools::Tools::MultiEditTool.new
|
|
17
|
+
# tool.execute(path: "./lib/foo.rb", edits: [
|
|
18
|
+
# { old_string: "def bar", new_string: "def baz" },
|
|
19
|
+
# { old_string: "BAR", new_string: "BAZ", replace_all: true }
|
|
20
|
+
# ])
|
|
21
|
+
class MultiEditTool < ::RubyLLM::Tool
|
|
22
|
+
class BadEdit < StandardError; end
|
|
23
|
+
|
|
24
|
+
MAX_BYTES = 10 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
def self.name = 'multi_edit'
|
|
27
|
+
|
|
28
|
+
description "Apply multiple find/replace edits to a single file atomically. Provide edits as " \
|
|
29
|
+
"an array of objects with old_string, new_string, and optional replace_all. Edits " \
|
|
30
|
+
"run in order; if any fails to apply cleanly, no changes are written."
|
|
31
|
+
|
|
32
|
+
parameters do
|
|
33
|
+
string :path, description: "File to edit, relative to root."
|
|
34
|
+
array :edits, description: "Array of edits, applied in order." do
|
|
35
|
+
object do
|
|
36
|
+
string :old_string, description: "Exact text to replace. Include surrounding lines so it's unique."
|
|
37
|
+
string :new_string, description: "Replacement text."
|
|
38
|
+
boolean :replace_all, description: "Replace every occurrence instead of requiring a unique match. Default false.", required: false
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param root [String] optional, defaults to the current directory
|
|
44
|
+
# @param logger [Logger] optional logger
|
|
45
|
+
def initialize(root: nil, logger: nil)
|
|
46
|
+
@root = root || Dir.pwd
|
|
47
|
+
@logger = logger || RubyLLM.logger
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param path [String]
|
|
51
|
+
# @param edits [Array<Hash>]
|
|
52
|
+
#
|
|
53
|
+
# @return [String, Hash]
|
|
54
|
+
def execute(path:, edits:)
|
|
55
|
+
@logger.info("#{self.class.name}#execute path=#{path.inspect} edits=#{Array(edits).size}")
|
|
56
|
+
|
|
57
|
+
return { error: "edits must be a non-empty array" } unless edits.is_a?(Array) && !edits.empty?
|
|
58
|
+
|
|
59
|
+
real = resolve!(path)
|
|
60
|
+
return { error: "not a file: #{path}" } unless File.file?(real)
|
|
61
|
+
return { error: "file too large (> #{MAX_BYTES} bytes)" } if File.size(real) > MAX_BYTES
|
|
62
|
+
|
|
63
|
+
original = File.read(real).scrub
|
|
64
|
+
content = original
|
|
65
|
+
total = 0
|
|
66
|
+
|
|
67
|
+
edits.each_with_index do |edit, i|
|
|
68
|
+
content, n = apply_edit(content, edit, i + 1, path)
|
|
69
|
+
total += n
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
allowed = SharedTools.execute?(tool: self.class.to_s, stuff: "Apply #{edits.size} edit(s) to #{path}")
|
|
73
|
+
unless allowed
|
|
74
|
+
@logger.warn("User declined to edit #{path}")
|
|
75
|
+
return { error: "User declined to edit #{path}" }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
File.write(real, content)
|
|
79
|
+
"Applied #{edits.size} edit#{edits.size == 1 ? '' : 's'} to #{path} " \
|
|
80
|
+
"(#{total} replacement#{total == 1 ? '' : 's'}, #{original.bytesize} -> #{content.bytesize} bytes)"
|
|
81
|
+
rescue SecurityError => e
|
|
82
|
+
@logger.error("#{self.class.name} path denied: #{e.message}")
|
|
83
|
+
{ error: e.message }
|
|
84
|
+
rescue BadEdit => e
|
|
85
|
+
@logger.error("#{self.class.name} bad edit: #{e.message}")
|
|
86
|
+
{ error: e.message }
|
|
87
|
+
rescue => e
|
|
88
|
+
@logger.error("#{self.class.name} failed: #{e.message}")
|
|
89
|
+
{ error: e.message }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def apply_edit(content, edit, index, path)
|
|
95
|
+
edit = normalize_edit(edit)
|
|
96
|
+
old_s = edit["old_string"].to_s
|
|
97
|
+
new_s = edit["new_string"].to_s
|
|
98
|
+
replace_all = [true, "true"].include?(edit["replace_all"])
|
|
99
|
+
|
|
100
|
+
raise BadEdit, "edit #{index}: old_string must not be empty" if old_s.empty?
|
|
101
|
+
raise BadEdit, "edit #{index}: old_string and new_string are identical" if old_s == new_s
|
|
102
|
+
|
|
103
|
+
if replace_all
|
|
104
|
+
count = 0
|
|
105
|
+
updated = content.gsub(old_s) { count += 1; new_s }
|
|
106
|
+
raise BadEdit, "edit #{index}: old_string not found in #{path}" if count.zero?
|
|
107
|
+
|
|
108
|
+
[updated, count]
|
|
109
|
+
else
|
|
110
|
+
count = content.scan(Regexp.new(Regexp.escape(old_s))).size
|
|
111
|
+
raise BadEdit, "edit #{index}: old_string not found in #{path}" if count.zero?
|
|
112
|
+
if count > 1
|
|
113
|
+
raise BadEdit, "edit #{index}: old_string is ambiguous (#{count} matches in #{path}); " \
|
|
114
|
+
"add surrounding context or set replace_all"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
[content.sub(old_s) { new_s }, 1]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def normalize_edit(edit)
|
|
122
|
+
edit.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def resolve!(path)
|
|
126
|
+
root = Pathname.new(File.expand_path(@root))
|
|
127
|
+
resolved = (root + path).cleanpath
|
|
128
|
+
raise SecurityError, "path escapes root: #{path}" unless resolved.ascend.any? { |ancestor| ancestor == root }
|
|
129
|
+
|
|
130
|
+
resolved.to_s
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -26,7 +26,7 @@ module SharedTools
|
|
|
26
26
|
On Linux uses notify-send, zenity (or terminal fallback), and espeak-ng/espeak.
|
|
27
27
|
DESC
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
parameters do
|
|
30
30
|
string :action, description: <<~TEXT.strip
|
|
31
31
|
The notification action to perform:
|
|
32
32
|
* `notify` — Non-blocking desktop banner notification.
|
|
@@ -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
|