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