shared_tools 0.4.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/README.md +211 -7
  4. data/lib/shared_tools/apply_patch_tool.rb +4 -0
  5. data/lib/shared_tools/bash_tool.rb +4 -0
  6. data/lib/shared_tools/bundle_tool.rb +4 -0
  7. data/lib/shared_tools/command_guard.rb +35 -0
  8. data/lib/shared_tools/csv_read_tool.rb +4 -0
  9. data/lib/shared_tools/csv_write_tool.rb +4 -0
  10. data/lib/shared_tools/data_path.rb +52 -0
  11. data/lib/shared_tools/diff_tool.rb +4 -0
  12. data/lib/shared_tools/download_file_tool.rb +4 -0
  13. data/lib/shared_tools/gem_tool.rb +4 -0
  14. data/lib/shared_tools/git_tool.rb +5 -0
  15. data/lib/shared_tools/glob_tool.rb +4 -0
  16. data/lib/shared_tools/http_request_tool.rb +4 -0
  17. data/lib/shared_tools/json_query_tool.rb +4 -0
  18. data/lib/shared_tools/lint_tool.rb +4 -0
  19. data/lib/shared_tools/mcp/brave_search_client.rb +18 -16
  20. data/lib/shared_tools/mcp/chart_client.rb +18 -15
  21. data/lib/shared_tools/mcp/client_support.rb +103 -0
  22. data/lib/shared_tools/mcp/github_client.rb +23 -19
  23. data/lib/shared_tools/mcp/hugging_face_client.rb +22 -22
  24. data/lib/shared_tools/mcp/memory_client.rb +20 -15
  25. data/lib/shared_tools/mcp/notion_client.rb +21 -19
  26. data/lib/shared_tools/mcp/playwright_client.rb +19 -15
  27. data/lib/shared_tools/mcp/sequential_thinking_client.rb +17 -14
  28. data/lib/shared_tools/mcp/slack_client.rb +30 -25
  29. data/lib/shared_tools/mcp/tavily_client.rb +21 -21
  30. data/lib/shared_tools/mcp.rb +32 -41
  31. data/lib/shared_tools/multi_edit_tool.rb +4 -0
  32. data/lib/shared_tools/parse_ruby_tool.rb +4 -0
  33. data/lib/shared_tools/process_kill_tool.rb +4 -0
  34. data/lib/shared_tools/process_list_tool.rb +4 -0
  35. data/lib/shared_tools/process_output_tool.rb +4 -0
  36. data/lib/shared_tools/process_registry.rb +224 -0
  37. data/lib/shared_tools/process_runner.rb +69 -0
  38. data/lib/shared_tools/process_start_tool.rb +4 -0
  39. data/lib/shared_tools/python_tests_tool.rb +4 -0
  40. data/lib/shared_tools/replace_in_files_tool.rb +4 -0
  41. data/lib/shared_tools/ruby_outline.rb +207 -0
  42. data/lib/shared_tools/run_tests_tool.rb +4 -0
  43. data/lib/shared_tools/text_diff.rb +80 -0
  44. data/lib/shared_tools/todo_write_tool.rb +4 -0
  45. data/lib/shared_tools/toml_parser.rb +409 -0
  46. data/lib/shared_tools/toml_query_tool.rb +4 -0
  47. data/lib/shared_tools/tools/apply_patch_tool.rb +122 -0
  48. data/lib/shared_tools/tools/bash_tool.rb +102 -0
  49. data/lib/shared_tools/tools/browser/click_tool.rb +1 -1
  50. data/lib/shared_tools/tools/browser/inspect_tool.rb +1 -1
  51. data/lib/shared_tools/tools/browser/page_inspect_tool.rb +1 -1
  52. data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +1 -1
  53. data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +1 -1
  54. data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +1 -1
  55. data/lib/shared_tools/tools/browser/visit_tool.rb +1 -1
  56. data/lib/shared_tools/tools/browser_tool.rb +1 -1
  57. data/lib/shared_tools/tools/bundle_tool.rb +96 -0
  58. data/lib/shared_tools/tools/calculator_tool.rb +1 -1
  59. data/lib/shared_tools/tools/clipboard_tool.rb +1 -1
  60. data/lib/shared_tools/tools/composite_analysis_tool.rb +1 -1
  61. data/lib/shared_tools/tools/computer_tool.rb +1 -1
  62. data/lib/shared_tools/tools/cron_tool.rb +1 -1
  63. data/lib/shared_tools/tools/csv_read_tool.rb +82 -0
  64. data/lib/shared_tools/tools/csv_write_tool.rb +67 -0
  65. data/lib/shared_tools/tools/current_date_time_tool.rb +1 -1
  66. data/lib/shared_tools/tools/data_science_kit.rb +1 -1
  67. data/lib/shared_tools/tools/database_query_tool.rb +1 -1
  68. data/lib/shared_tools/tools/database_tool.rb +1 -1
  69. data/lib/shared_tools/tools/diff_tool.rb +48 -0
  70. data/lib/shared_tools/tools/disk/directory_create_tool.rb +1 -1
  71. data/lib/shared_tools/tools/disk/directory_delete_tool.rb +1 -1
  72. data/lib/shared_tools/tools/disk/directory_list_tool.rb +1 -1
  73. data/lib/shared_tools/tools/disk/directory_move_tool.rb +1 -1
  74. data/lib/shared_tools/tools/disk/file_create_tool.rb +1 -1
  75. data/lib/shared_tools/tools/disk/file_delete_tool.rb +1 -1
  76. data/lib/shared_tools/tools/disk/file_move_tool.rb +1 -1
  77. data/lib/shared_tools/tools/disk/file_read_tool.rb +1 -1
  78. data/lib/shared_tools/tools/disk/file_replace_tool.rb +1 -1
  79. data/lib/shared_tools/tools/disk/file_write_tool.rb +1 -1
  80. data/lib/shared_tools/tools/disk_tool.rb +1 -1
  81. data/lib/shared_tools/tools/dns_tool.rb +1 -1
  82. data/lib/shared_tools/tools/doc/docx_reader_tool.rb +1 -1
  83. data/lib/shared_tools/tools/doc/pdf_reader_tool.rb +1 -1
  84. data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +1 -1
  85. data/lib/shared_tools/tools/doc/text_reader_tool.rb +1 -1
  86. data/lib/shared_tools/tools/doc_tool.rb +1 -1
  87. data/lib/shared_tools/tools/docker/compose_run_tool.rb +1 -1
  88. data/lib/shared_tools/tools/download_file_tool.rb +87 -0
  89. data/lib/shared_tools/tools/enabler.rb +2 -2
  90. data/lib/shared_tools/tools/error_handling_tool.rb +1 -1
  91. data/lib/shared_tools/tools/eval/python_eval_tool.rb +1 -1
  92. data/lib/shared_tools/tools/eval/ruby_eval_tool.rb +1 -1
  93. data/lib/shared_tools/tools/eval/shell_eval_tool.rb +1 -1
  94. data/lib/shared_tools/tools/eval_tool.rb +1 -1
  95. data/lib/shared_tools/tools/gem_tool.rb +157 -0
  96. data/lib/shared_tools/tools/git/add_tool.rb +73 -0
  97. data/lib/shared_tools/tools/git/blame_tool.rb +77 -0
  98. data/lib/shared_tools/tools/git/branch_tool.rb +55 -0
  99. data/lib/shared_tools/tools/git/checkout_tool.rb +69 -0
  100. data/lib/shared_tools/tools/git/commit_tool.rb +73 -0
  101. data/lib/shared_tools/tools/git/diff_tool.rb +69 -0
  102. data/lib/shared_tools/tools/git/grep_tool.rb +82 -0
  103. data/lib/shared_tools/tools/git/helpers.rb +70 -0
  104. data/lib/shared_tools/tools/git/log_tool.rb +71 -0
  105. data/lib/shared_tools/tools/git/show_tool.rb +66 -0
  106. data/lib/shared_tools/tools/git/status_tool.rb +46 -0
  107. data/lib/shared_tools/tools/git.rb +18 -0
  108. data/lib/shared_tools/tools/git_tool.rb +148 -0
  109. data/lib/shared_tools/tools/glob_tool.rb +75 -0
  110. data/lib/shared_tools/tools/http_helpers.rb +119 -0
  111. data/lib/shared_tools/tools/http_request_tool.rb +91 -0
  112. data/lib/shared_tools/tools/json_query_tool.rb +81 -0
  113. data/lib/shared_tools/tools/lint_tool.rb +93 -0
  114. data/lib/shared_tools/tools/multi_edit_tool.rb +134 -0
  115. data/lib/shared_tools/tools/notification_tool.rb +1 -1
  116. data/lib/shared_tools/tools/parse_ruby_tool.rb +127 -0
  117. data/lib/shared_tools/tools/process_kill_tool.rb +55 -0
  118. data/lib/shared_tools/tools/process_list_tool.rb +41 -0
  119. data/lib/shared_tools/tools/process_output_tool.rb +69 -0
  120. data/lib/shared_tools/tools/process_start_tool.rb +0 -0
  121. data/lib/shared_tools/tools/python_tests_tool.rb +105 -0
  122. data/lib/shared_tools/tools/replace_in_files_tool.rb +166 -0
  123. data/lib/shared_tools/tools/run_tests_tool.rb +108 -0
  124. data/lib/shared_tools/tools/search_codebase_tool.rb +178 -47
  125. data/lib/shared_tools/tools/secure_tool_template.rb +1 -1
  126. data/lib/shared_tools/tools/system_info_tool.rb +1 -1
  127. data/lib/shared_tools/tools/todo_write_tool.rb +81 -0
  128. data/lib/shared_tools/tools/toml_query_tool.rb +81 -0
  129. data/lib/shared_tools/tools/toolchain_helpers.rb +64 -0
  130. data/lib/shared_tools/tools/tree_tool.rb +100 -0
  131. data/lib/shared_tools/tools/weather_tool.rb +1 -1
  132. data/lib/shared_tools/tools/web_fetch_tool.rb +85 -0
  133. data/lib/shared_tools/tools/workflow_manager_tool.rb +1 -1
  134. data/lib/shared_tools/tools/yaml_query_tool.rb +86 -0
  135. data/lib/shared_tools/tree_tool.rb +4 -0
  136. data/lib/shared_tools/url_guard.rb +106 -0
  137. data/lib/shared_tools/utilities.rb +2 -2
  138. data/lib/shared_tools/version.rb +1 -1
  139. data/lib/shared_tools/web_fetch_tool.rb +4 -0
  140. data/lib/shared_tools/yaml_query_tool.rb +4 -0
  141. data/lib/shared_tools.rb +1 -1
  142. metadata +516 -409
  143. data/lib/shared_tools/mcp/streamable_http_patch.rb +0 -42
  144. data/lib/shared_tools/tools/version.rb +0 -7
@@ -1,53 +1,44 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # lib/shared_tools/mcp.rb
2
4
  #
3
- # MCP (Model Context Protocol) client support for SharedTools.
4
- # Requires the ruby_llm-mcp gem >= 0.7.0 and RubyLLM >= 1.9.0.
5
- #
6
- # @see https://github.com/patvice/ruby_llm-mcp
7
- # @see https://www.rubyllm-mcp.com
8
- #
9
- # Two categories of client are provided, both requiring no pre-installed binaries:
5
+ # MCP (Model Context Protocol) server definitions for SharedTools.
6
+ # Requires ruby_llm >= 2.1, whose built-in RubyLLM::MCP is the client.
7
+ # (The separate ruby_llm-mcp gem is not used and cannot be loaded alongside it.)
10
8
  #
11
- # REMOTE HTTP (transport: :streamable)
12
- # Connect to cloud-hosted MCP servers. Requires only an API key.
9
+ # Each server is a RubyLLM::MCP subclass under SharedTools::MCP. Requiring a
10
+ # definition has no side effects — nothing is started, downloaded or connected
11
+ # until a chat or robot first asks the server for its tools.
13
12
  #
14
- # require 'shared_tools/mcp/tavily_client' # Web search (TAVILY_API_KEY)
13
+ # REMOTE HTTP (Streamable HTTP)
14
+ # SharedTools::MCP::Tavily # Web search (TAVILY_API_KEY)
15
15
  #
16
- # NPX AUTO-DOWNLOAD (transport: :stdio via npx -y)
17
- # The npm package is downloaded on first use. Requires Node.js / npx.
16
+ # LOCAL BINARY (stdio; install with Klass.install_packages!)
17
+ # SharedTools::MCP::GitHub # GITHUB_PERSONAL_ACCESS_TOKEN
18
+ # SharedTools::MCP::Notion # NOTION_TOKEN
19
+ # SharedTools::MCP::Slack # SLACK_MCP_XOXP_TOKEN or SLACK_MCP_XOXB_TOKEN
20
+ # SharedTools::MCP::HuggingFace # HF_TOKEN
18
21
  #
19
- # require 'shared_tools/mcp/memory_client' # Persistent knowledge graph
20
- # require 'shared_tools/mcp/sequential_thinking_client' # Chain-of-thought reasoning
21
- # require 'shared_tools/mcp/chart_client' # Chart / visualisation generation
22
- # require 'shared_tools/mcp/brave_search_client' # Web search (BRAVE_API_KEY)
22
+ # NPX AUTO-DOWNLOAD (stdio via npx -y; requires Node.js)
23
+ # SharedTools::MCP::Memory # Persistent knowledge graph
24
+ # SharedTools::MCP::SequentialThinking # Chain-of-thought reasoning
25
+ # SharedTools::MCP::Chart # Chart / visualisation generation
26
+ # SharedTools::MCP::BraveSearch # Web search (BRAVE_API_KEY)
27
+ # SharedTools::MCP::Playwright # Browser automation
23
28
  #
24
- # Requiring this file loads ALL available clients concurrently using threads.
25
- # Each client's transport connection is established in parallel, so total startup
26
- # time equals the slowest single client rather than the sum of all clients.
29
+ # Requiring this file loads every definition and records, per server, whether
30
+ # its required environment variables are set (see SharedTools.mcp_status).
27
31
  #
28
- # Clients whose API keys are missing are silently skipped.
29
- #
30
- # After loading, access clients via:
31
- # client = RubyLLM::MCP.clients["client-name"]
32
- # chat = RubyLLM.chat.with_tools(*client.tools)
32
+ # require 'shared_tools/mcp'
33
+ # chat = RubyLLM.chat.with_mcp(*SharedTools::MCP.available)
34
+ # robot = RobotLab.build(name: "bot", mcp: SharedTools::MCP.available)
33
35
 
34
- require_relative "mcp/streamable_http_patch"
36
+ require_relative "mcp/client_support"
35
37
 
36
- threads = Dir[File.join(__dir__, "mcp", "*_client.rb")].map do |path|
37
- # Derive the canonical client name from the filename:
38
- # "brave_search_client.rb" → "brave-search", "hugging_face_client.rb" → "hugging-face"
39
- client_name = File.basename(path, "_client.rb").tr("_", "-")
38
+ Dir[File.join(__dir__, "mcp", "*_client.rb")].sort.each { |path| require path }
40
39
 
41
- Thread.new do
42
- require path
43
- SharedTools.record_mcp_result(client_name)
44
- rescue Exception => e
45
- # LoadError (missing env var / package) is a ScriptError, not a StandardError,
46
- # so bare `rescue => e` does not catch it. We record the failure and warn so
47
- # that missing-key clients are simply skipped when requiring mcp.rb as a whole.
48
- SharedTools.record_mcp_result(client_name, error: e)
49
- warn "SharedTools::MCP — skipping #{File.basename(path)}: #{e.message}"
50
- end
40
+ SharedTools::MCP.clients.each do |klass|
41
+ missing = klass.missing_envars
42
+ error = missing.empty? ? nil : LoadError.new("Missing envars: #{missing.join(', ')}")
43
+ SharedTools.record_mcp_result(klass.default_name, error: error)
51
44
  end
52
-
53
- threads.each(&:join)
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ # Shim: require 'shared_tools/multi_edit_tool'
3
+ require 'shared_tools'
4
+ require 'shared_tools/tools/multi_edit_tool'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ # Shim: require 'shared_tools/parse_ruby_tool'
3
+ require 'shared_tools'
4
+ require 'shared_tools/tools/parse_ruby_tool'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ # Shim: require 'shared_tools/process_kill_tool'
3
+ require 'shared_tools'
4
+ require 'shared_tools/tools/process_kill_tool'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ # Shim: require 'shared_tools/process_list_tool'
3
+ require 'shared_tools'
4
+ require 'shared_tools/tools/process_list_tool'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ # Shim: require 'shared_tools/process_output_tool'
3
+ require 'shared_tools'
4
+ require 'shared_tools/tools/process_output_tool'
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module SharedTools
6
+ # A single managed background process, used by ProcessRegistry. Its stdout
7
+ # and stderr are drained continuously by reader threads into bounded
8
+ # buffers, so a chatty child can't deadlock on a full pipe or grow memory
9
+ # without limit. Output is read incrementally (each read returns only what's
10
+ # new since the last one). The child runs in its own process group so it —
11
+ # and any children it spawns — can be killed together.
12
+ class ManagedProcess
13
+ MAX_BUFFER = 256 * 1024 # retain at most this many unread bytes per stream
14
+
15
+ attr_reader :id, :name, :argv, :pid, :started_at
16
+
17
+ def initialize(id:, argv:, env:, chdir:, name:)
18
+ @id = id
19
+ @argv = argv
20
+ @name = name
21
+ @started_at = Time.now
22
+ @mutex = Mutex.new
23
+ @out = +""
24
+ @err = +""
25
+ @out_dropped = false
26
+ @err_dropped = false
27
+
28
+ opts = { unsetenv_others: true, pgroup: true }
29
+ opts[:chdir] = chdir if chdir && !chdir.to_s.empty?
30
+
31
+ stdin, stdout, stderr, @wait_thr = Open3.popen3(env, *argv, **opts)
32
+ @pid = @wait_thr.pid
33
+ begin
34
+ stdin.close
35
+ rescue StandardError
36
+ nil
37
+ end
38
+
39
+ @readers = [drain(stdout, :out), drain(stderr, :err)]
40
+ end
41
+
42
+ def running?
43
+ @wait_thr.alive?
44
+ end
45
+
46
+ def status
47
+ running? ? :running : :exited
48
+ end
49
+
50
+ def exit_code
51
+ return nil if running?
52
+
53
+ @wait_thr.value.exitstatus
54
+ rescue StandardError
55
+ nil
56
+ end
57
+
58
+ def age
59
+ Time.now - @started_at
60
+ end
61
+
62
+ # Returns and clears the output accumulated since the previous call.
63
+ def read_new
64
+ @mutex.synchronize do
65
+ data = { out: @out.dup, err: @err.dup, out_dropped: @out_dropped, err_dropped: @err_dropped }
66
+ @out = +""
67
+ @err = +""
68
+ @out_dropped = false
69
+ @err_dropped = false
70
+ data
71
+ end
72
+ end
73
+
74
+ # SIGTERM the whole tree, escalate to SIGKILL after a grace period.
75
+ # Descendants are collected up front (before the parent dies and they get
76
+ # reparented), then signalled both via the process group and individually
77
+ # — so cleanup is reliable even in sandboxes that don't deliver
78
+ # process-group signals to non-leader members.
79
+ def kill(grace: 2.0)
80
+ return unless @wait_thr.alive?
81
+
82
+ targets = [@pid] + descendants(@pid)
83
+ signal_group("TERM")
84
+ targets.each { |pid| signal_pid(pid, "TERM") }
85
+
86
+ deadline = Time.now + grace
87
+ sleep(0.05) while @wait_thr.alive? && Time.now < deadline
88
+
89
+ signal_group("KILL")
90
+ targets.each { |pid| signal_pid(pid, "KILL") }
91
+ begin
92
+ @wait_thr.value
93
+ rescue StandardError
94
+ nil
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ def signal_group(sig)
101
+ Process.kill("-#{sig}", @pid) # negative pid => the process group
102
+ rescue StandardError
103
+ nil
104
+ end
105
+
106
+ def signal_pid(pid, sig)
107
+ Process.kill(sig, pid)
108
+ rescue StandardError
109
+ nil
110
+ end
111
+
112
+ # All transitive children of root, via /proc (Linux). Collected before the
113
+ # parent is killed, so the parent->child links are still intact. No-ops
114
+ # (returns []) on hosts without /proc, e.g. macOS.
115
+ def descendants(root)
116
+ return [] unless File.directory?("/proc")
117
+
118
+ children = Hash.new { |h, k| h[k] = [] }
119
+ Dir.glob("/proc/[0-9]*/stat").each do |file|
120
+ data = File.read(file)
121
+ open_paren = data.index("(")
122
+ close_paren = data.rindex(")")
123
+ next unless open_paren && close_paren
124
+
125
+ pid = data[0...open_paren].to_i
126
+ ppid = data[(close_paren + 2)..].to_s.split[1].to_i
127
+ children[ppid] << pid
128
+ rescue StandardError
129
+ next
130
+ end
131
+
132
+ result = []
133
+ queue = children[root].dup
134
+ until queue.empty?
135
+ pid = queue.shift
136
+ next if result.include?(pid)
137
+
138
+ result << pid
139
+ queue.concat(children[pid])
140
+ end
141
+ result
142
+ end
143
+
144
+ def drain(io, which)
145
+ Thread.new do
146
+ loop do
147
+ append(which, io.readpartial(4096))
148
+ end
149
+ rescue EOFError, IOError
150
+ nil
151
+ ensure
152
+ begin
153
+ io.close
154
+ rescue StandardError
155
+ nil
156
+ end
157
+ end
158
+ end
159
+
160
+ def append(which, chunk)
161
+ @mutex.synchronize do
162
+ buf = which == :out ? @out : @err
163
+ buf << chunk
164
+ next unless buf.bytesize > MAX_BUFFER
165
+
166
+ overflow = buf.bytesize - MAX_BUFFER
167
+ buf.replace(buf.byteslice(overflow, MAX_BUFFER) || +"")
168
+ if which == :out
169
+ @out_dropped = true
170
+ else
171
+ @err_dropped = true
172
+ end
173
+ end
174
+ end
175
+ end
176
+
177
+ # Thread-safe registry of background processes, shared across tool calls
178
+ # within a process. Holds an upper bound on concurrent live processes and
179
+ # cleans everything up at interpreter exit so nothing is orphaned.
180
+ module ProcessRegistry
181
+ class LimitError < StandardError; end
182
+
183
+ @mutex = Mutex.new
184
+ @procs = {}
185
+ @counter = 0
186
+
187
+ class << self
188
+ def start(argv:, env:, chdir:, name:, max: 8)
189
+ @mutex.synchronize do
190
+ live = @procs.values.count(&:running?)
191
+ raise LimitError, "too many background processes (limit #{max}); kill some first" if live >= max
192
+
193
+ @counter += 1
194
+ id = "proc_#{@counter}"
195
+ @procs[id] = ManagedProcess.new(id: id, argv: argv, env: env, chdir: chdir, name: name)
196
+ id
197
+ end
198
+ end
199
+
200
+ def get(id)
201
+ @mutex.synchronize { @procs[id] }
202
+ end
203
+
204
+ def all
205
+ @mutex.synchronize { @procs.values.dup }
206
+ end
207
+
208
+ def delete(id)
209
+ @mutex.synchronize { @procs.delete(id) }
210
+ end
211
+
212
+ def kill_all
213
+ all.each { |proc| proc.kill(grace: 0.2) }
214
+ end
215
+
216
+ def reset!
217
+ all.each { |proc| proc.kill(grace: 0.2) }
218
+ @mutex.synchronize { @procs = {} }
219
+ end
220
+ end
221
+ end
222
+ end
223
+
224
+ at_exit { SharedTools::ProcessRegistry.kill_all }
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "timeout"
5
+
6
+ module SharedTools
7
+ # Shared subprocess runner used by BashTool and the toolchain tools
8
+ # (BundleTool, LintTool, RunTestsTool, PythonTestsTool). Always array-form
9
+ # (no shell), streams both pipes so a chatty child can't deadlock on a full
10
+ # pipe buffer, and hard-kills the process if it blows the wall-clock budget.
11
+ #
12
+ # Returns [stdout, stderr, status] where status is a Process::Status or the
13
+ # symbol :timeout.
14
+ module ProcessRunner
15
+ module_function
16
+
17
+ def capture(argv, env: {}, stdin: nil, timeout: 30, unsetenv_others: true, chdir: nil)
18
+ opts = { unsetenv_others: unsetenv_others }
19
+ opts[:chdir] = chdir if chdir
20
+ Open3.popen3(env, *argv, **opts) do |i, o, e, thr|
21
+ write_stdin(i, stdin)
22
+ pump(o, e, thr, timeout)
23
+ end
24
+ end
25
+
26
+ def write_stdin(io, stdin)
27
+ io.write(stdin) if stdin && !stdin.empty?
28
+ rescue StandardError
29
+ # Child may have exited/closed the pipe before we finished writing.
30
+ nil
31
+ ensure
32
+ begin
33
+ io.close
34
+ rescue StandardError
35
+ nil
36
+ end
37
+ end
38
+
39
+ def pump(stdout, stderr, wait_thr, timeout)
40
+ out = +""
41
+ err = +""
42
+ readers = [stdout, stderr]
43
+
44
+ Timeout.timeout(timeout) do
45
+ until readers.empty?
46
+ ready, = IO.select(readers)
47
+ ready.each do |io|
48
+ chunk = io.read_nonblock(4096)
49
+ (io.equal?(stdout) ? out : err) << chunk
50
+ rescue IO::WaitReadable
51
+ next
52
+ rescue EOFError
53
+ readers.delete(io)
54
+ end
55
+ end
56
+ [out, err, wait_thr.value]
57
+ end
58
+ rescue Timeout::Error
59
+ kill(wait_thr.pid)
60
+ [out, err, :timeout]
61
+ end
62
+
63
+ def kill(pid)
64
+ Process.kill("KILL", pid)
65
+ rescue StandardError
66
+ nil
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ # Shim: require 'shared_tools/process_start_tool'
3
+ require 'shared_tools'
4
+ require 'shared_tools/tools/process_start_tool'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ # Shim: require 'shared_tools/python_tests_tool'
3
+ require 'shared_tools'
4
+ require 'shared_tools/tools/python_tests_tool'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ # Shim: require 'shared_tools/replace_in_files_tool'
3
+ require 'shared_tools'
4
+ require 'shared_tools/tools/replace_in_files_tool'
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+
5
+ module SharedTools
6
+ # Extracts a structural outline (classes, modules, methods, constants) from
7
+ # Ruby source, with line numbers and nesting depth, for ParseRubyTool.
8
+ # Parsing only — the code is never executed.
9
+ #
10
+ # Two backends sit behind one dispatcher and produce identical Entry lists:
11
+ #
12
+ # * PrismBackend — used automatically when `require "prism"` succeeds.
13
+ # Prism is bundled with Ruby 3.3+, so on a modern Ruby this needs no gem
14
+ # install. It is the same parser the VM itself uses, so its line numbers
15
+ # and structure are authoritative.
16
+ # * RipperBackend — the stdlib fallback for runtimes that don't bundle
17
+ # Prism. Dependency-free, always present.
18
+ module RubyOutline
19
+ class ParseError < StandardError; end
20
+
21
+ Entry = Struct.new(:kind, :name, :line, :depth, keyword_init: true)
22
+
23
+ module_function
24
+
25
+ # True when the Prism backend can be loaded on this Ruby. Memoized.
26
+ def prism_available?
27
+ return @prism_available if defined?(@prism_available)
28
+
29
+ @prism_available = begin
30
+ require "prism"
31
+ true
32
+ rescue LoadError
33
+ false
34
+ end
35
+ end
36
+
37
+ def active_backend
38
+ prism_available? ? PrismBackend : RipperBackend
39
+ end
40
+
41
+ # Returns an Array<Entry> in source order. Raises ParseError on a syntax
42
+ # error. Pass backend: to force a specific one.
43
+ def extract(source, backend: active_backend)
44
+ backend.extract(source.to_s)
45
+ end
46
+
47
+ # --- Prism backend ----------------------------------------------------
48
+ module PrismBackend
49
+ module_function
50
+
51
+ def extract(source)
52
+ require "prism"
53
+ result = Prism.parse(source)
54
+ raise ParseError, "source is not valid Ruby (syntax error)" unless result.success?
55
+
56
+ acc = []
57
+ visit(result.value, 0, acc)
58
+ acc
59
+ end
60
+
61
+ def visit(node, depth, acc)
62
+ return if node.nil?
63
+
64
+ case node
65
+ when Prism::ClassNode
66
+ acc << Entry.new(kind: :class, name: node.constant_path.slice,
67
+ line: node.constant_path.location.start_line, depth: depth)
68
+ visit(node.body, depth + 1, acc)
69
+ when Prism::ModuleNode
70
+ acc << Entry.new(kind: :module, name: node.constant_path.slice,
71
+ line: node.constant_path.location.start_line, depth: depth)
72
+ visit(node.body, depth + 1, acc)
73
+ when Prism::SingletonClassNode
74
+ acc << Entry.new(kind: :singleton_class, name: "<< #{node.expression.slice}",
75
+ line: node.location.start_line, depth: depth)
76
+ visit(node.body, depth + 1, acc)
77
+ when Prism::DefNode
78
+ name = node.receiver ? "#{node.receiver.slice}.#{node.name}" : node.name.to_s
79
+ acc << Entry.new(kind: :method, name: name, line: node.name_loc.start_line, depth: depth)
80
+ # method bodies are not descended into
81
+ when Prism::ConstantWriteNode
82
+ acc << Entry.new(kind: :constant, name: node.name.to_s,
83
+ line: node.name_loc.start_line, depth: depth)
84
+ else
85
+ node.compact_child_nodes.each { |child| visit(child, depth, acc) }
86
+ end
87
+ end
88
+ end
89
+
90
+ # --- Ripper backend (stdlib) ------------------------------------------
91
+ module RipperBackend
92
+ module_function
93
+
94
+ def extract(source)
95
+ sexp = Ripper.sexp(source.to_s)
96
+ raise ParseError, "source is not valid Ruby (syntax error)" if sexp.nil?
97
+
98
+ acc = []
99
+ walk(sexp, 0, acc)
100
+ acc
101
+ end
102
+
103
+ def walk(node, depth, acc)
104
+ return unless node.is_a?(Array)
105
+
106
+ if node[0].is_a?(Symbol)
107
+ dispatch(node, depth, acc)
108
+ else
109
+ node.each { |child| walk(child, depth, acc) }
110
+ end
111
+ end
112
+
113
+ def dispatch(node, depth, acc)
114
+ case node[0]
115
+ when :program
116
+ walk(node[1], depth, acc)
117
+ when :class
118
+ acc << Entry.new(kind: :class, name: const_name(node[1]), line: line_of(node[1]), depth: depth)
119
+ walk(node[3], depth + 1, acc) # bodystmt
120
+ when :module
121
+ acc << Entry.new(kind: :module, name: const_name(node[1]), line: line_of(node[1]), depth: depth)
122
+ walk(node[2], depth + 1, acc)
123
+ when :sclass # class << self
124
+ acc << Entry.new(kind: :singleton_class, name: "<< #{simple_name(node[1])}", line: line_of(node), depth: depth)
125
+ walk(node[2], depth + 1, acc)
126
+ when :def
127
+ acc << Entry.new(kind: :method, name: ident_name(node[1]), line: line_of(node[1]), depth: depth)
128
+ when :defs # def self.x / def Recv.x
129
+ name = "#{simple_name(node[1])}.#{ident_name(node[3])}"
130
+ acc << Entry.new(kind: :method, name: name, line: line_of(node[3]), depth: depth)
131
+ when :bodystmt
132
+ walk(node[1], depth, acc)
133
+ when :assign
134
+ handle_assign(node, depth, acc)
135
+ else
136
+ node[1..].each { |child| walk(child, depth, acc) }
137
+ end
138
+ end
139
+
140
+ def handle_assign(node, depth, acc)
141
+ target = node[1]
142
+ return unless target.is_a?(Array) && target[0] == :var_field
143
+
144
+ field = target[1]
145
+ return unless field.is_a?(Array) && field[0] == :@const
146
+
147
+ acc << Entry.new(kind: :constant, name: field[1], line: field[2]&.first, depth: depth)
148
+ end
149
+
150
+ def const_name(node)
151
+ case node && node[0]
152
+ when :const_ref, :top_const_ref, :var_ref
153
+ simple_name(node[1])
154
+ when :const_path_ref # Foo::Bar
155
+ "#{const_name(node[1])}::#{simple_name(node[2])}"
156
+ else
157
+ simple_name(node)
158
+ end
159
+ end
160
+
161
+ def simple_name(node)
162
+ return node.to_s unless node.is_a?(Array)
163
+
164
+ case node[0]
165
+ when :@const, :@ident, :@ivar, :@gvar, :@kw then node[1].to_s
166
+ when :const_ref, :var_ref, :var_field then simple_name(node[1])
167
+ when :const_path_ref then "#{simple_name(node[1])}::#{simple_name(node[2])}"
168
+ else
169
+ leaf = find_name_leaf(node)
170
+ leaf ? leaf[1].to_s : "?"
171
+ end
172
+ end
173
+
174
+ def ident_name(node)
175
+ return node.to_s unless node.is_a?(Array)
176
+
177
+ node[0] == :@ident || node[0] == :@const || node[0] == :@kw ? node[1].to_s : simple_name(node)
178
+ end
179
+
180
+ def line_of(node)
181
+ return nil unless node.is_a?(Array)
182
+
183
+ if node.size == 3 && node[2].is_a?(Array) && node[2].size == 2 &&
184
+ node[2][0].is_a?(Integer) && node[0].is_a?(Symbol)
185
+ return node[2][0]
186
+ end
187
+
188
+ node.each do |child|
189
+ line = line_of(child)
190
+ return line if line
191
+ end
192
+ nil
193
+ end
194
+
195
+ def find_name_leaf(node)
196
+ return nil unless node.is_a?(Array)
197
+ return node if %i[@const @ident @kw].include?(node[0])
198
+
199
+ node.each do |child|
200
+ found = find_name_leaf(child)
201
+ return found if found
202
+ end
203
+ nil
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ # Shim: require 'shared_tools/run_tests_tool'
3
+ require 'shared_tools'
4
+ require 'shared_tools/tools/run_tests_tool'