shared_tools 0.4.2 → 0.6.0

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