shared_tools 0.4.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/README.md +211 -7
  4. data/lib/shared_tools/apply_patch_tool.rb +4 -0
  5. data/lib/shared_tools/bash_tool.rb +4 -0
  6. data/lib/shared_tools/bundle_tool.rb +4 -0
  7. data/lib/shared_tools/command_guard.rb +35 -0
  8. data/lib/shared_tools/csv_read_tool.rb +4 -0
  9. data/lib/shared_tools/csv_write_tool.rb +4 -0
  10. data/lib/shared_tools/data_path.rb +52 -0
  11. data/lib/shared_tools/diff_tool.rb +4 -0
  12. data/lib/shared_tools/download_file_tool.rb +4 -0
  13. data/lib/shared_tools/gem_tool.rb +4 -0
  14. data/lib/shared_tools/git_tool.rb +5 -0
  15. data/lib/shared_tools/glob_tool.rb +4 -0
  16. data/lib/shared_tools/http_request_tool.rb +4 -0
  17. data/lib/shared_tools/json_query_tool.rb +4 -0
  18. data/lib/shared_tools/lint_tool.rb +4 -0
  19. data/lib/shared_tools/mcp/brave_search_client.rb +18 -16
  20. data/lib/shared_tools/mcp/chart_client.rb +18 -15
  21. data/lib/shared_tools/mcp/client_support.rb +103 -0
  22. data/lib/shared_tools/mcp/github_client.rb +23 -19
  23. data/lib/shared_tools/mcp/hugging_face_client.rb +22 -22
  24. data/lib/shared_tools/mcp/memory_client.rb +20 -15
  25. data/lib/shared_tools/mcp/notion_client.rb +21 -19
  26. data/lib/shared_tools/mcp/playwright_client.rb +19 -15
  27. data/lib/shared_tools/mcp/sequential_thinking_client.rb +17 -14
  28. data/lib/shared_tools/mcp/slack_client.rb +30 -25
  29. data/lib/shared_tools/mcp/tavily_client.rb +21 -21
  30. data/lib/shared_tools/mcp.rb +32 -41
  31. data/lib/shared_tools/multi_edit_tool.rb +4 -0
  32. data/lib/shared_tools/parse_ruby_tool.rb +4 -0
  33. data/lib/shared_tools/process_kill_tool.rb +4 -0
  34. data/lib/shared_tools/process_list_tool.rb +4 -0
  35. data/lib/shared_tools/process_output_tool.rb +4 -0
  36. data/lib/shared_tools/process_registry.rb +224 -0
  37. data/lib/shared_tools/process_runner.rb +69 -0
  38. data/lib/shared_tools/process_start_tool.rb +4 -0
  39. data/lib/shared_tools/python_tests_tool.rb +4 -0
  40. data/lib/shared_tools/replace_in_files_tool.rb +4 -0
  41. data/lib/shared_tools/ruby_outline.rb +207 -0
  42. data/lib/shared_tools/run_tests_tool.rb +4 -0
  43. data/lib/shared_tools/text_diff.rb +80 -0
  44. data/lib/shared_tools/todo_write_tool.rb +4 -0
  45. data/lib/shared_tools/toml_parser.rb +409 -0
  46. data/lib/shared_tools/toml_query_tool.rb +4 -0
  47. data/lib/shared_tools/tools/apply_patch_tool.rb +122 -0
  48. data/lib/shared_tools/tools/bash_tool.rb +102 -0
  49. data/lib/shared_tools/tools/browser/click_tool.rb +1 -1
  50. data/lib/shared_tools/tools/browser/inspect_tool.rb +1 -1
  51. data/lib/shared_tools/tools/browser/page_inspect_tool.rb +1 -1
  52. data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +1 -1
  53. data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +1 -1
  54. data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +1 -1
  55. data/lib/shared_tools/tools/browser/visit_tool.rb +1 -1
  56. data/lib/shared_tools/tools/browser_tool.rb +1 -1
  57. data/lib/shared_tools/tools/bundle_tool.rb +96 -0
  58. data/lib/shared_tools/tools/calculator_tool.rb +1 -1
  59. data/lib/shared_tools/tools/clipboard_tool.rb +1 -1
  60. data/lib/shared_tools/tools/composite_analysis_tool.rb +1 -1
  61. data/lib/shared_tools/tools/computer_tool.rb +1 -1
  62. data/lib/shared_tools/tools/cron_tool.rb +1 -1
  63. data/lib/shared_tools/tools/csv_read_tool.rb +82 -0
  64. data/lib/shared_tools/tools/csv_write_tool.rb +67 -0
  65. data/lib/shared_tools/tools/current_date_time_tool.rb +1 -1
  66. data/lib/shared_tools/tools/data_science_kit.rb +1 -1
  67. data/lib/shared_tools/tools/database_query_tool.rb +1 -1
  68. data/lib/shared_tools/tools/database_tool.rb +1 -1
  69. data/lib/shared_tools/tools/diff_tool.rb +48 -0
  70. data/lib/shared_tools/tools/disk/directory_create_tool.rb +1 -1
  71. data/lib/shared_tools/tools/disk/directory_delete_tool.rb +1 -1
  72. data/lib/shared_tools/tools/disk/directory_list_tool.rb +1 -1
  73. data/lib/shared_tools/tools/disk/directory_move_tool.rb +1 -1
  74. data/lib/shared_tools/tools/disk/file_create_tool.rb +1 -1
  75. data/lib/shared_tools/tools/disk/file_delete_tool.rb +1 -1
  76. data/lib/shared_tools/tools/disk/file_move_tool.rb +1 -1
  77. data/lib/shared_tools/tools/disk/file_read_tool.rb +1 -1
  78. data/lib/shared_tools/tools/disk/file_replace_tool.rb +1 -1
  79. data/lib/shared_tools/tools/disk/file_write_tool.rb +1 -1
  80. data/lib/shared_tools/tools/disk_tool.rb +1 -1
  81. data/lib/shared_tools/tools/dns_tool.rb +1 -1
  82. data/lib/shared_tools/tools/doc/docx_reader_tool.rb +1 -1
  83. data/lib/shared_tools/tools/doc/pdf_reader_tool.rb +1 -1
  84. data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +1 -1
  85. data/lib/shared_tools/tools/doc/text_reader_tool.rb +1 -1
  86. data/lib/shared_tools/tools/doc_tool.rb +1 -1
  87. data/lib/shared_tools/tools/docker/compose_run_tool.rb +1 -1
  88. data/lib/shared_tools/tools/download_file_tool.rb +87 -0
  89. data/lib/shared_tools/tools/enabler.rb +2 -2
  90. data/lib/shared_tools/tools/error_handling_tool.rb +1 -1
  91. data/lib/shared_tools/tools/eval/python_eval_tool.rb +1 -1
  92. data/lib/shared_tools/tools/eval/ruby_eval_tool.rb +1 -1
  93. data/lib/shared_tools/tools/eval/shell_eval_tool.rb +1 -1
  94. data/lib/shared_tools/tools/eval_tool.rb +1 -1
  95. data/lib/shared_tools/tools/gem_tool.rb +157 -0
  96. data/lib/shared_tools/tools/git/add_tool.rb +73 -0
  97. data/lib/shared_tools/tools/git/blame_tool.rb +77 -0
  98. data/lib/shared_tools/tools/git/branch_tool.rb +55 -0
  99. data/lib/shared_tools/tools/git/checkout_tool.rb +69 -0
  100. data/lib/shared_tools/tools/git/commit_tool.rb +73 -0
  101. data/lib/shared_tools/tools/git/diff_tool.rb +69 -0
  102. data/lib/shared_tools/tools/git/grep_tool.rb +82 -0
  103. data/lib/shared_tools/tools/git/helpers.rb +70 -0
  104. data/lib/shared_tools/tools/git/log_tool.rb +71 -0
  105. data/lib/shared_tools/tools/git/show_tool.rb +66 -0
  106. data/lib/shared_tools/tools/git/status_tool.rb +46 -0
  107. data/lib/shared_tools/tools/git.rb +18 -0
  108. data/lib/shared_tools/tools/git_tool.rb +148 -0
  109. data/lib/shared_tools/tools/glob_tool.rb +75 -0
  110. data/lib/shared_tools/tools/http_helpers.rb +119 -0
  111. data/lib/shared_tools/tools/http_request_tool.rb +91 -0
  112. data/lib/shared_tools/tools/json_query_tool.rb +81 -0
  113. data/lib/shared_tools/tools/lint_tool.rb +93 -0
  114. data/lib/shared_tools/tools/multi_edit_tool.rb +134 -0
  115. data/lib/shared_tools/tools/notification_tool.rb +1 -1
  116. data/lib/shared_tools/tools/parse_ruby_tool.rb +127 -0
  117. data/lib/shared_tools/tools/process_kill_tool.rb +55 -0
  118. data/lib/shared_tools/tools/process_list_tool.rb +41 -0
  119. data/lib/shared_tools/tools/process_output_tool.rb +69 -0
  120. data/lib/shared_tools/tools/process_start_tool.rb +0 -0
  121. data/lib/shared_tools/tools/python_tests_tool.rb +105 -0
  122. data/lib/shared_tools/tools/replace_in_files_tool.rb +166 -0
  123. data/lib/shared_tools/tools/run_tests_tool.rb +108 -0
  124. data/lib/shared_tools/tools/search_codebase_tool.rb +178 -47
  125. data/lib/shared_tools/tools/secure_tool_template.rb +1 -1
  126. data/lib/shared_tools/tools/system_info_tool.rb +1 -1
  127. data/lib/shared_tools/tools/todo_write_tool.rb +81 -0
  128. data/lib/shared_tools/tools/toml_query_tool.rb +81 -0
  129. data/lib/shared_tools/tools/toolchain_helpers.rb +64 -0
  130. data/lib/shared_tools/tools/tree_tool.rb +100 -0
  131. data/lib/shared_tools/tools/weather_tool.rb +1 -1
  132. data/lib/shared_tools/tools/web_fetch_tool.rb +85 -0
  133. data/lib/shared_tools/tools/workflow_manager_tool.rb +1 -1
  134. data/lib/shared_tools/tools/yaml_query_tool.rb +86 -0
  135. data/lib/shared_tools/tree_tool.rb +4 -0
  136. data/lib/shared_tools/url_guard.rb +106 -0
  137. data/lib/shared_tools/utilities.rb +2 -2
  138. data/lib/shared_tools/version.rb +1 -1
  139. data/lib/shared_tools/web_fetch_tool.rb +4 -0
  140. data/lib/shared_tools/yaml_query_tool.rb +4 -0
  141. data/lib/shared_tools.rb +1 -1
  142. metadata +516 -409
  143. data/lib/shared_tools/mcp/streamable_http_patch.rb +0 -42
  144. data/lib/shared_tools/tools/version.rb +0 -7
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require_relative '../../shared_tools'
5
+
6
+ module SharedTools
7
+ module Tools
8
+ # Finds files matching a glob pattern within a directory and returns
9
+ # their paths relative to the glob base, sorted. Each hit is re-checked
10
+ # against the base so a symlinked match that points outside it is
11
+ # dropped. Read-only, in-process — no authorization prompt required.
12
+ #
13
+ # @example
14
+ # tool = SharedTools::Tools::GlobTool.new
15
+ # tool.execute(pattern: "**/*.rb")
16
+ # tool.execute(pattern: "*.rb", base: "app/models")
17
+ class GlobTool < ::RubyLLM::Tool
18
+ MAX_RESULTS = 1_000
19
+
20
+ def self.name = 'glob'
21
+
22
+ description "Find files matching a glob pattern within a directory (e.g. '**/*.rb', " \
23
+ "'app/models/*.rb'). Returns matching paths, sorted, relative to base."
24
+
25
+ parameters do
26
+ string :pattern, description: "Glob pattern such as '**/*.rb', evaluated relative to base."
27
+ string :base, description: "Directory to glob from. Defaults to the current directory.", required: false
28
+ end
29
+
30
+ # @param logger [Logger] optional logger
31
+ def initialize(logger: nil)
32
+ @logger = logger || RubyLLM.logger
33
+ end
34
+
35
+ # @param pattern [String]
36
+ # @param base [String]
37
+ #
38
+ # @return [String, Hash]
39
+ def execute(pattern:, base: ".")
40
+ @logger.info("#{self.class.name}#execute pattern=#{pattern.inspect} base=#{base.inspect}")
41
+
42
+ return { error: "pattern must be provided" } if pattern.to_s.empty?
43
+ return { error: "pattern may not contain '..'" } if pattern.include?("..")
44
+
45
+ return { error: "not a directory: #{base}" } unless File.directory?(File.expand_path(base))
46
+
47
+ root = File.realpath(File.expand_path(base))
48
+
49
+ base_pn = Pathname.new(root)
50
+ results = Dir.glob(File.join(root, pattern), File::FNM_PATHNAME).sort.filter_map do |match|
51
+ in_base?(root, match) ? Pathname.new(match).relative_path_from(base_pn).to_s : nil
52
+ end
53
+
54
+ capped = results.first(MAX_RESULTS)
55
+ body = +"#{results.size} match#{results.size == 1 ? '' : 'es'}"
56
+ body << " (showing first #{MAX_RESULTS})" if results.size > MAX_RESULTS
57
+ body << " for #{pattern}:\n"
58
+ capped.each { |rel| body << rel << "\n" }
59
+ body
60
+ rescue => e
61
+ @logger.error("#{self.class.name} failed: #{e.message}")
62
+ { error: e.message }
63
+ end
64
+
65
+ private
66
+
67
+ def in_base?(root, match)
68
+ real = File.realpath(match)
69
+ (real == root || real.start_with?(root + File::SEPARATOR)) && File.exist?(match)
70
+ rescue Errno::ENOENT
71
+ false
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require_relative "../../shared_tools"
6
+ require_relative "../url_guard"
7
+
8
+ module SharedTools
9
+ module Tools
10
+ # Shared guarded-HTTP behavior for WebFetchTool, HttpRequestTool, and
11
+ # DownloadFileTool. Every request URL and every redirect hop passes
12
+ # through UrlGuard, and the socket is pinned to the vetted IP so a second
13
+ # DNS lookup can't redirect to an internal address. SSRF protection is
14
+ # always on — there is no bypass.
15
+ module HttpHelpers
16
+ HTTP_TIMEOUT = 10
17
+ MAX_REDIRECTS = 5
18
+ MAX_FETCH_BYTES = 5 * 1024 * 1024
19
+ USER_AGENT = "SharedTools/#{SharedTools::VERSION} (+https://github.com/madbomber/shared_tools)"
20
+
21
+ Response = Struct.new(:status, :headers, :body, :final_url, keyword_init: true)
22
+
23
+ class FetchError < StandardError; end
24
+
25
+ def url_guard
26
+ SharedTools::UrlGuard.new(allowlist: @web_allowlist || [], denylist: @web_denylist || [])
27
+ end
28
+
29
+ # GET with redirect following and a body-size cap. Each hop is
30
+ # re-guarded and re-pinned.
31
+ def guarded_get(url)
32
+ checker = url_guard
33
+ current = url
34
+ seen = 0
35
+
36
+ loop do
37
+ resolution = checker.resolve!(current)
38
+ response, body = perform(resolution.uri, "GET", nil, nil, resolution.address)
39
+
40
+ if redirect?(response)
41
+ location = response["location"]
42
+ raise FetchError, "redirect with no Location header (status #{response.code})" unless location
43
+ raise FetchError, "too many redirects (max #{MAX_REDIRECTS})" if seen >= MAX_REDIRECTS
44
+
45
+ seen += 1
46
+ current = URI.join(resolution.uri.to_s, location).to_s
47
+ next
48
+ end
49
+
50
+ return Response.new(status: response.code.to_i, headers: response.to_hash,
51
+ body: body, final_url: resolution.uri.to_s)
52
+ end
53
+ rescue SharedTools::UrlGuard::Blocked
54
+ raise
55
+ rescue StandardError => e
56
+ raise FetchError, e.message
57
+ end
58
+
59
+ # A single request with an explicit method/headers/body (no redirect
60
+ # following). Used by HttpRequestTool.
61
+ def guarded_request(method, url, headers: {}, body: nil)
62
+ resolution = url_guard.resolve!(url)
63
+ response, raw = perform(resolution.uri, method.to_s.upcase, headers, body, resolution.address)
64
+ Response.new(status: response.code.to_i, headers: response.to_hash, body: raw, final_url: resolution.uri.to_s)
65
+ rescue SharedTools::UrlGuard::Blocked
66
+ raise
67
+ rescue StandardError => e
68
+ raise FetchError, e.message
69
+ end
70
+
71
+ private
72
+
73
+ def redirect?(response)
74
+ response.is_a?(Net::HTTPRedirection)
75
+ end
76
+
77
+ def perform(uri, method, headers, body, pin)
78
+ request = build_request(uri, method, headers, body)
79
+
80
+ http = Net::HTTP.new(uri.host, uri.port)
81
+ http.use_ssl = uri.scheme == "https"
82
+ http.ipaddr = pin if pin # pin to the vetted address (closes DNS rebinding)
83
+ http.open_timeout = HTTP_TIMEOUT
84
+ http.read_timeout = HTTP_TIMEOUT
85
+
86
+ captured = +""
87
+ response = http.start do |conn|
88
+ conn.request(request) do |res|
89
+ unless res.is_a?(Net::HTTPRedirection)
90
+ res.read_body do |chunk|
91
+ captured << chunk
92
+ break if captured.bytesize >= MAX_FETCH_BYTES
93
+ end
94
+ end
95
+ res
96
+ end
97
+ end
98
+
99
+ [response, captured]
100
+ end
101
+
102
+ def build_request(uri, method, headers, body)
103
+ klass = begin
104
+ Net::HTTP.const_get(method.capitalize)
105
+ rescue NameError
106
+ nil
107
+ end
108
+ raise FetchError, "unsupported HTTP method: #{method}" unless klass && klass <= Net::HTTPRequest
109
+
110
+ request = klass.new(uri)
111
+ request["User-Agent"] = USER_AGENT
112
+ request["Accept"] ||= "*/*"
113
+ Array(headers).each { |k, v| request[k.to_s] = v.to_s } if headers
114
+ request.body = body.to_s if body && !body.to_s.empty?
115
+ request
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../shared_tools"
4
+ require_relative "http_helpers"
5
+
6
+ module SharedTools
7
+ module Tools
8
+ # A general HTTP client, UrlGuard-protected. Read methods (GET/HEAD) work
9
+ # by default; mutating methods (POST/PUT/PATCH/DELETE) require user
10
+ # authorization (see SharedTools.execute?), so the safe default can't
11
+ # change remote state. Returns status, key headers, and the body.
12
+ #
13
+ # @example
14
+ # tool = SharedTools::Tools::HttpRequestTool.new
15
+ # tool.execute(url: "https://api.example.com/status")
16
+ # tool.execute(url: "https://api.example.com/items", method: "POST", body: '{"name":"x"}')
17
+ class HttpRequestTool < ::RubyLLM::Tool
18
+ include HttpHelpers
19
+
20
+ READ_METHODS = %w[GET HEAD].freeze
21
+ MUTATING_METHODS = %w[POST PUT PATCH DELETE].freeze
22
+ SHOWN_HEADERS = %w[content-type content-length location etag last-modified].freeze
23
+
24
+ def self.name = 'http_request'
25
+
26
+ description "Make an HTTP request to an http/https URL and return the status, headers, and " \
27
+ "body. GET and HEAD are available by default; POST/PUT/PATCH/DELETE require user " \
28
+ "authorization. Cannot reach private, loopback, or cloud-metadata addresses."
29
+
30
+ parameters do
31
+ string :url, description: "The http/https URL to request."
32
+ string :method, description: "HTTP method: GET (default), HEAD, POST, PUT, PATCH, DELETE.", required: false
33
+ object :headers, description: "Optional request headers as a JSON object of name/value strings.", required: false do
34
+ additional_properties true
35
+ end
36
+ string :body, description: "Optional request body (for POST/PUT/PATCH).", required: false
37
+ end
38
+
39
+ # @param logger [Logger] optional logger
40
+ def initialize(logger: nil)
41
+ @logger = logger || RubyLLM.logger
42
+ end
43
+
44
+ # @param url [String]
45
+ # @param method [String]
46
+ # @param headers [Hash, nil]
47
+ # @param body [String, nil]
48
+ #
49
+ # @return [String, Hash]
50
+ def execute(url:, method: "GET", headers: nil, body: nil)
51
+ @logger.info("#{self.class.name}#execute url=#{url.inspect} method=#{method}")
52
+
53
+ verb = method.to_s.strip.upcase
54
+ verb = "GET" if verb.empty?
55
+ return { error: "unsupported method: #{verb}" } unless (READ_METHODS + MUTATING_METHODS).include?(verb)
56
+
57
+ if MUTATING_METHODS.include?(verb)
58
+ allowed = SharedTools.execute?(tool: self.class.to_s, stuff: "#{verb} #{url}")
59
+ unless allowed
60
+ @logger.warn("User declined #{verb} #{url}")
61
+ return { error: "User declined the request" }
62
+ end
63
+ end
64
+
65
+ response = guarded_request(verb, url, headers: headers, body: body)
66
+ return { error: "HTTP #{response.status} from #{response.final_url}" } if response.status >= 400
67
+
68
+ format_response(verb, response)
69
+ rescue SharedTools::UrlGuard::Blocked => e
70
+ @logger.error("#{self.class.name} url blocked: #{e.message}")
71
+ { error: e.message }
72
+ rescue HttpHelpers::FetchError => e
73
+ @logger.error("#{self.class.name} request failed: #{e.message}")
74
+ { error: "request failed: #{e.message}" }
75
+ end
76
+
77
+ private
78
+
79
+ def format_response(verb, response)
80
+ lines = ["#{verb} #{response.final_url} -> #{response.status}"]
81
+ SHOWN_HEADERS.each do |name|
82
+ value = Array(response.headers[name]).first
83
+ lines << "#{name}: #{value}" if value
84
+ end
85
+ body = response.body.to_s.scrub
86
+ lines << "\n--- body ---\n#{body}" unless body.empty?
87
+ lines.join("\n")
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../../shared_tools'
5
+
6
+ module SharedTools
7
+ module Tools
8
+ # Parses JSON (from a file or an inline string) and either pretty-prints
9
+ # it or extracts a value with a dot/bracket path. In-process, read-only.
10
+ #
11
+ # Path syntax: dot-separated keys, [n] for array indices, and [] to map a
12
+ # field across an array. Examples:
13
+ # users[0].name users[].email config.server.port
14
+ #
15
+ # @example
16
+ # tool = SharedTools::Tools::JsonQueryTool.new
17
+ # tool.execute(path: "./config.json", query: "servers[0].host")
18
+ # tool.execute(json: '{"a":{"b":1}}', query: "a.b")
19
+ class JsonQueryTool < ::RubyLLM::Tool
20
+ MAX_BYTES = 5 * 1024 * 1024
21
+
22
+ def self.name = 'json_query'
23
+
24
+ description "Query JSON with a path expression, or pretty-print it. Provide either a file " \
25
+ "path or an inline json string. Path syntax: keys separated by dots, [n] for " \
26
+ "array index, [] to map over an array (e.g. users[].name)."
27
+
28
+ parameters do
29
+ string :path, description: "JSON file to read. Provide this or json.", required: false
30
+ string :json, description: "Inline JSON string. Provide this or path.", required: false
31
+ string :query, description: "Path expression to extract, e.g. 'users[0].name'. Omit to pretty-print. Optional.", required: false
32
+ end
33
+
34
+ # @param logger [Logger] optional logger
35
+ def initialize(logger: nil)
36
+ @logger = logger || RubyLLM.logger
37
+ end
38
+
39
+ # @param path [String, nil]
40
+ # @param json [String, nil]
41
+ # @param query [String, nil]
42
+ #
43
+ # @return [String, Hash]
44
+ def execute(path: nil, json: nil, query: nil)
45
+ @logger.info("#{self.class.name}#execute path=#{path.inspect} query=#{query.inspect}")
46
+
47
+ source = load_source(path, json)
48
+ return source if source.is_a?(Hash) # error
49
+
50
+ data = JSON.parse(source)
51
+ result = query.to_s.strip.empty? ? data : SharedTools::DataPath.query(data, query)
52
+ JSON.pretty_generate(result)
53
+ rescue JSON::ParserError => e
54
+ @logger.error("#{self.class.name} invalid JSON: #{e.message}")
55
+ { error: "invalid JSON: #{e.message}" }
56
+ rescue SharedTools::DataPath::Error => e
57
+ @logger.error("#{self.class.name} bad query: #{e.message}")
58
+ { error: e.message }
59
+ rescue => e
60
+ @logger.error("#{self.class.name} failed: #{e.message}")
61
+ { error: e.message }
62
+ end
63
+
64
+ private
65
+
66
+ def load_source(path, json)
67
+ if path && !path.to_s.empty?
68
+ return { error: "file not found: #{path}" } unless File.exist?(path)
69
+ return { error: "not a file: #{path}" } unless File.file?(path)
70
+ return { error: "file too large (> #{MAX_BYTES} bytes)" } if File.size(path) > MAX_BYTES
71
+
72
+ File.read(path).scrub
73
+ elsif json && !json.to_s.empty?
74
+ json.to_s
75
+ else
76
+ { error: "provide either path or json" }
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,93 @@
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 RuboCop or Standard over a project. Offenses are a normal result,
9
+ # not a tool error. With autocorrect, the linter rewrites files in place,
10
+ # so this requires user authorization (see SharedTools.execute?) even
11
+ # without autocorrect, since it executes an external command.
12
+ #
13
+ # @example
14
+ # tool = SharedTools::Tools::LintTool.new(root: "./my-project")
15
+ # tool.execute
16
+ # tool.execute(path: "lib/foo.rb", autocorrect: true)
17
+ class LintTool < ::RubyLLM::Tool
18
+ include ToolchainHelpers
19
+
20
+ LINTERS = %w[rubocop standard].freeze
21
+ DEFAULT_TIMEOUT = 120
22
+
23
+ def self.name = 'lint'
24
+
25
+ description "Lint a project with RuboCop or Standard. Auto-detects (Standard if .standard.yml " \
26
+ "is present, else RuboCop) or set linter explicitly. Optionally scope to a path and " \
27
+ "apply safe autocorrections. Uses `bundle exec` when a Gemfile exists."
28
+
29
+ parameters do
30
+ string :path, description: "Limit linting to this path, relative to root. Optional.", required: false
31
+ string :linter, description: "Force the linter: rubocop or standard. Default: auto-detect.", required: false
32
+ boolean :autocorrect, description: "Apply safe autocorrections, rewriting files in place. Default false.", 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 linter [String, nil]
44
+ # @param autocorrect [Boolean]
45
+ #
46
+ # @return [String, Hash]
47
+ def execute(path: nil, linter: nil, autocorrect: false)
48
+ @logger.info("#{self.class.name}#execute path=#{path.inspect} linter=#{linter.inspect} autocorrect=#{autocorrect}")
49
+
50
+ tool = (linter || detect_linter).to_s
51
+ return { error: "unknown linter: #{tool} (use #{LINTERS.join(', ')})" } unless LINTERS.include?(tool)
52
+
53
+ rel = jail_relative(path)
54
+
55
+ allowed = SharedTools.execute?(tool: self.class.to_s, stuff: "Lint#{autocorrect ? ' with autocorrect' : ''}: #{tool}#{rel ? " #{rel}" : ''}")
56
+ unless allowed
57
+ @logger.warn("User declined to lint")
58
+ return { error: "User declined to lint" }
59
+ end
60
+
61
+ out, err, status = run_in_project(build_args(tool, rel, autocorrect), use_bundle: true, timeout: DEFAULT_TIMEOUT)
62
+ toolchain_output(out, err, status,
63
+ pass_label: "LINT CLEAN",
64
+ fail_label: autocorrect ? "LINT: offenses found (autocorrected where possible)" : "LINT: offenses found",
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_linter
77
+ File.exist?(File.join(@root, ".standard.yml")) ? "standard" : "rubocop"
78
+ end
79
+
80
+ def build_args(tool, rel, autocorrect)
81
+ if tool == "standard"
82
+ args = ["standardrb"]
83
+ args << "--fix" if autocorrect
84
+ else
85
+ args = ["rubocop", "--no-color"]
86
+ args << "--autocorrect" if autocorrect
87
+ end
88
+ args << rel if rel
89
+ args
90
+ end
91
+ end
92
+ end
93
+ end
@@ -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.