shared_tools 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -0
  3. data/README.md +211 -7
  4. data/lib/shared_tools/apply_patch_tool.rb +4 -0
  5. data/lib/shared_tools/bash_tool.rb +4 -0
  6. data/lib/shared_tools/bundle_tool.rb +4 -0
  7. data/lib/shared_tools/command_guard.rb +35 -0
  8. data/lib/shared_tools/csv_read_tool.rb +4 -0
  9. data/lib/shared_tools/csv_write_tool.rb +4 -0
  10. data/lib/shared_tools/data_path.rb +52 -0
  11. data/lib/shared_tools/diff_tool.rb +4 -0
  12. data/lib/shared_tools/download_file_tool.rb +4 -0
  13. data/lib/shared_tools/gem_tool.rb +4 -0
  14. data/lib/shared_tools/git_tool.rb +5 -0
  15. data/lib/shared_tools/glob_tool.rb +4 -0
  16. data/lib/shared_tools/http_request_tool.rb +4 -0
  17. data/lib/shared_tools/json_query_tool.rb +4 -0
  18. data/lib/shared_tools/lint_tool.rb +4 -0
  19. data/lib/shared_tools/mcp/brave_search_client.rb +18 -16
  20. data/lib/shared_tools/mcp/chart_client.rb +18 -15
  21. data/lib/shared_tools/mcp/client_support.rb +103 -0
  22. data/lib/shared_tools/mcp/github_client.rb +23 -19
  23. data/lib/shared_tools/mcp/hugging_face_client.rb +22 -22
  24. data/lib/shared_tools/mcp/memory_client.rb +20 -15
  25. data/lib/shared_tools/mcp/notion_client.rb +21 -19
  26. data/lib/shared_tools/mcp/playwright_client.rb +19 -15
  27. data/lib/shared_tools/mcp/sequential_thinking_client.rb +17 -14
  28. data/lib/shared_tools/mcp/slack_client.rb +30 -25
  29. data/lib/shared_tools/mcp/tavily_client.rb +21 -21
  30. data/lib/shared_tools/mcp.rb +32 -41
  31. data/lib/shared_tools/multi_edit_tool.rb +4 -0
  32. data/lib/shared_tools/parse_ruby_tool.rb +4 -0
  33. data/lib/shared_tools/process_kill_tool.rb +4 -0
  34. data/lib/shared_tools/process_list_tool.rb +4 -0
  35. data/lib/shared_tools/process_output_tool.rb +4 -0
  36. data/lib/shared_tools/process_registry.rb +224 -0
  37. data/lib/shared_tools/process_runner.rb +69 -0
  38. data/lib/shared_tools/process_start_tool.rb +4 -0
  39. data/lib/shared_tools/python_tests_tool.rb +4 -0
  40. data/lib/shared_tools/replace_in_files_tool.rb +4 -0
  41. data/lib/shared_tools/ruby_outline.rb +207 -0
  42. data/lib/shared_tools/run_tests_tool.rb +4 -0
  43. data/lib/shared_tools/text_diff.rb +80 -0
  44. data/lib/shared_tools/todo_write_tool.rb +4 -0
  45. data/lib/shared_tools/toml_parser.rb +409 -0
  46. data/lib/shared_tools/toml_query_tool.rb +4 -0
  47. data/lib/shared_tools/tools/apply_patch_tool.rb +122 -0
  48. data/lib/shared_tools/tools/bash_tool.rb +102 -0
  49. data/lib/shared_tools/tools/browser/click_tool.rb +1 -1
  50. data/lib/shared_tools/tools/browser/inspect_tool.rb +1 -1
  51. data/lib/shared_tools/tools/browser/page_inspect_tool.rb +1 -1
  52. data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +1 -1
  53. data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +1 -1
  54. data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +1 -1
  55. data/lib/shared_tools/tools/browser/visit_tool.rb +1 -1
  56. data/lib/shared_tools/tools/browser_tool.rb +1 -1
  57. data/lib/shared_tools/tools/bundle_tool.rb +96 -0
  58. data/lib/shared_tools/tools/calculator_tool.rb +12 -2
  59. data/lib/shared_tools/tools/clipboard_tool.rb +1 -1
  60. data/lib/shared_tools/tools/composite_analysis_tool.rb +1 -1
  61. data/lib/shared_tools/tools/computer_tool.rb +1 -1
  62. data/lib/shared_tools/tools/cron_tool.rb +1 -1
  63. data/lib/shared_tools/tools/csv_read_tool.rb +82 -0
  64. data/lib/shared_tools/tools/csv_write_tool.rb +67 -0
  65. data/lib/shared_tools/tools/current_date_time_tool.rb +1 -1
  66. data/lib/shared_tools/tools/data_science_kit.rb +1 -1
  67. data/lib/shared_tools/tools/database_query_tool.rb +12 -2
  68. data/lib/shared_tools/tools/database_tool.rb +1 -1
  69. data/lib/shared_tools/tools/diff_tool.rb +48 -0
  70. data/lib/shared_tools/tools/disk/directory_create_tool.rb +1 -1
  71. data/lib/shared_tools/tools/disk/directory_delete_tool.rb +1 -1
  72. data/lib/shared_tools/tools/disk/directory_list_tool.rb +1 -1
  73. data/lib/shared_tools/tools/disk/directory_move_tool.rb +1 -1
  74. data/lib/shared_tools/tools/disk/file_create_tool.rb +1 -1
  75. data/lib/shared_tools/tools/disk/file_delete_tool.rb +1 -1
  76. data/lib/shared_tools/tools/disk/file_move_tool.rb +1 -1
  77. data/lib/shared_tools/tools/disk/file_read_tool.rb +1 -1
  78. data/lib/shared_tools/tools/disk/file_replace_tool.rb +1 -1
  79. data/lib/shared_tools/tools/disk/file_write_tool.rb +1 -1
  80. data/lib/shared_tools/tools/disk_tool.rb +1 -1
  81. data/lib/shared_tools/tools/dns_tool.rb +1 -1
  82. data/lib/shared_tools/tools/doc/docx_reader_tool.rb +1 -1
  83. data/lib/shared_tools/tools/doc/pdf_reader_tool.rb +1 -1
  84. data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +1 -1
  85. data/lib/shared_tools/tools/doc/text_reader_tool.rb +1 -1
  86. data/lib/shared_tools/tools/doc_tool.rb +1 -1
  87. data/lib/shared_tools/tools/docker/compose_run_tool.rb +1 -1
  88. data/lib/shared_tools/tools/download_file_tool.rb +87 -0
  89. data/lib/shared_tools/tools/enabler.rb +2 -2
  90. data/lib/shared_tools/tools/error_handling_tool.rb +1 -1
  91. data/lib/shared_tools/tools/eval/python_eval_tool.rb +1 -1
  92. data/lib/shared_tools/tools/eval/ruby_eval_tool.rb +1 -1
  93. data/lib/shared_tools/tools/eval/shell_eval_tool.rb +27 -6
  94. data/lib/shared_tools/tools/eval_tool.rb +1 -1
  95. data/lib/shared_tools/tools/gem_tool.rb +157 -0
  96. data/lib/shared_tools/tools/git/add_tool.rb +73 -0
  97. data/lib/shared_tools/tools/git/blame_tool.rb +77 -0
  98. data/lib/shared_tools/tools/git/branch_tool.rb +55 -0
  99. data/lib/shared_tools/tools/git/checkout_tool.rb +69 -0
  100. data/lib/shared_tools/tools/git/commit_tool.rb +73 -0
  101. data/lib/shared_tools/tools/git/diff_tool.rb +69 -0
  102. data/lib/shared_tools/tools/git/grep_tool.rb +82 -0
  103. data/lib/shared_tools/tools/git/helpers.rb +70 -0
  104. data/lib/shared_tools/tools/git/log_tool.rb +71 -0
  105. data/lib/shared_tools/tools/git/show_tool.rb +66 -0
  106. data/lib/shared_tools/tools/git/status_tool.rb +46 -0
  107. data/lib/shared_tools/tools/git.rb +18 -0
  108. data/lib/shared_tools/tools/git_tool.rb +148 -0
  109. data/lib/shared_tools/tools/glob_tool.rb +75 -0
  110. data/lib/shared_tools/tools/http_helpers.rb +119 -0
  111. data/lib/shared_tools/tools/http_request_tool.rb +91 -0
  112. data/lib/shared_tools/tools/json_query_tool.rb +81 -0
  113. data/lib/shared_tools/tools/lint_tool.rb +93 -0
  114. data/lib/shared_tools/tools/multi_edit_tool.rb +134 -0
  115. data/lib/shared_tools/tools/notification_tool.rb +1 -1
  116. data/lib/shared_tools/tools/parse_ruby_tool.rb +127 -0
  117. data/lib/shared_tools/tools/process_kill_tool.rb +55 -0
  118. data/lib/shared_tools/tools/process_list_tool.rb +41 -0
  119. data/lib/shared_tools/tools/process_output_tool.rb +69 -0
  120. data/lib/shared_tools/tools/process_start_tool.rb +0 -0
  121. data/lib/shared_tools/tools/python_tests_tool.rb +105 -0
  122. data/lib/shared_tools/tools/replace_in_files_tool.rb +166 -0
  123. data/lib/shared_tools/tools/run_tests_tool.rb +108 -0
  124. data/lib/shared_tools/tools/search_codebase_tool.rb +222 -0
  125. data/lib/shared_tools/tools/secure_tool_template.rb +1 -1
  126. data/lib/shared_tools/tools/system_info_tool.rb +1 -1
  127. data/lib/shared_tools/tools/todo_write_tool.rb +81 -0
  128. data/lib/shared_tools/tools/toml_query_tool.rb +81 -0
  129. data/lib/shared_tools/tools/toolchain_helpers.rb +64 -0
  130. data/lib/shared_tools/tools/tree_tool.rb +100 -0
  131. data/lib/shared_tools/tools/weather_tool.rb +12 -2
  132. data/lib/shared_tools/tools/web_fetch_tool.rb +85 -0
  133. data/lib/shared_tools/tools/workflow_manager_tool.rb +1 -1
  134. data/lib/shared_tools/tools/yaml_query_tool.rb +86 -0
  135. data/lib/shared_tools/tree_tool.rb +4 -0
  136. data/lib/shared_tools/url_guard.rb +106 -0
  137. data/lib/shared_tools/utilities.rb +2 -2
  138. data/lib/shared_tools/version.rb +1 -1
  139. data/lib/shared_tools/web_fetch_tool.rb +4 -0
  140. data/lib/shared_tools/yaml_query_tool.rb +4 -0
  141. data/lib/shared_tools.rb +1 -1
  142. metadata +516 -394
  143. data/lib/shared_tools/mcp/streamable_http_patch.rb +0 -42
  144. data/lib/shared_tools/tools/version.rb +0 -7
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../shared_tools'
4
+
5
+ module SharedTools
6
+ module Tools
7
+ # A tool for interacting with a git repository: status, diff, log, show,
8
+ # blame, branch, grep, add, commit, and checkout. Mutating actions (add,
9
+ # commit, checkout) require user authorization (see SharedTools.execute?).
10
+ #
11
+ # @example
12
+ # git = SharedTools::Tools::GitTool.new
13
+ # git.execute(action: SharedTools::Tools::GitTool::Action::STATUS)
14
+ # git.execute(action: SharedTools::Tools::GitTool::Action::LOG, count: 5)
15
+ # git.execute(action: SharedTools::Tools::GitTool::Action::COMMIT, message: "Fix the thing")
16
+ class GitTool < ::RubyLLM::Tool
17
+ def self.name = 'git_tool'
18
+
19
+ description <<~TEXT
20
+ A tool for interacting with a git repository. Read-only actions (status, diff, log, show,
21
+ blame, branch, grep) require no authorization. Mutating actions (add, commit, checkout)
22
+ require user authorization.
23
+ TEXT
24
+
25
+ module Action
26
+ STATUS = "status"
27
+ DIFF = "diff"
28
+ LOG = "log"
29
+ SHOW = "show"
30
+ BLAME = "blame"
31
+ BRANCH = "branch"
32
+ GREP = "grep"
33
+ ADD = "add"
34
+ COMMIT = "commit"
35
+ CHECKOUT = "checkout"
36
+ end
37
+
38
+ ACTIONS = [
39
+ Action::STATUS,
40
+ Action::DIFF,
41
+ Action::LOG,
42
+ Action::SHOW,
43
+ Action::BLAME,
44
+ Action::BRANCH,
45
+ Action::GREP,
46
+ Action::ADD,
47
+ Action::COMMIT,
48
+ Action::CHECKOUT,
49
+ ].freeze
50
+
51
+ parameters do
52
+ string :action, description: "The git operation to perform. One of: #{ACTIONS.join(', ')}"
53
+ boolean :staged, description: "For `#{Action::DIFF}`: show staged changes instead of unstaged.", required: false
54
+ string :path, description: "A file or directory path, relative to the repo root. Used by #{Action::DIFF}, #{Action::LOG}, #{Action::SHOW}, #{Action::BLAME}, #{Action::GREP}.", required: false
55
+ string :ref, description: "A commit/branch/tag. Used by #{Action::DIFF}, #{Action::SHOW}, #{Action::CHECKOUT}.", required: false
56
+ integer :count, description: "For `#{Action::LOG}`: number of commits to show.", required: false
57
+ integer :start_line, description: "For `#{Action::BLAME}`: first line of the range.", required: false
58
+ integer :end_line, description: "For `#{Action::BLAME}`: last line of the range.", required: false
59
+ boolean :all, description: "For `#{Action::BRANCH}`: include remote-tracking branches. For `#{Action::ADD}`: stage all changes. For `#{Action::COMMIT}`: stage tracked files first.", required: false
60
+ string :pattern, description: "For `#{Action::GREP}`: the pattern to search for.", required: false
61
+ boolean :ignore_case, description: "For `#{Action::GREP}`: case-insensitive search.", required: false
62
+ boolean :fixed, description: "For `#{Action::GREP}`: treat pattern as a literal string.", required: false
63
+ array :paths, of: :string, description: "For `#{Action::ADD}`: paths to stage.", required: false
64
+ string :message, description: "For `#{Action::COMMIT}`: the commit message.", required: false
65
+ boolean :create, description: "For `#{Action::CHECKOUT}`: create ref as a new branch.", required: false
66
+ end
67
+
68
+ # @param repo_root [String] optional, defaults to the current directory
69
+ # @param logger [Logger] optional logger
70
+ def initialize(repo_root: nil, logger: nil)
71
+ @repo_root = repo_root || Dir.pwd
72
+ @logger = logger || RubyLLM.logger
73
+ end
74
+
75
+ # @return [String, Hash]
76
+ def execute(action:, **opts)
77
+ @logger.info("GitTool#execute action=#{action}")
78
+
79
+ case action.to_s.downcase
80
+ when Action::STATUS then status_tool.execute
81
+ when Action::DIFF then diff_tool.execute(staged: opts[:staged] || false, path: opts[:path], ref: opts[:ref])
82
+ when Action::LOG then log_tool.execute(count: opts[:count] || Git::LogTool::DEFAULT_COUNT, path: opts[:path])
83
+ when Action::SHOW then show_tool.execute(ref: opts[:ref] || "HEAD", path: opts[:path])
84
+ when Action::BLAME then require_param!(:path, opts[:path]) && blame_tool.execute(path: opts[:path], start_line: opts[:start_line], end_line: opts[:end_line])
85
+ when Action::BRANCH then branch_tool.execute(all: opts[:all] || false)
86
+ when Action::GREP then require_param!(:pattern, opts[:pattern]) && grep_tool.execute(pattern: opts[:pattern], path: opts[:path], ignore_case: opts[:ignore_case] || false, fixed: opts[:fixed] || false)
87
+ when Action::ADD then add_tool.execute(paths: opts[:paths], all: opts[:all] || false)
88
+ when Action::COMMIT then require_param!(:message, opts[:message]) && commit_tool.execute(message: opts[:message], all: opts[:all] || false)
89
+ when Action::CHECKOUT then require_param!(:ref, opts[:ref]) && checkout_tool.execute(ref: opts[:ref], create: opts[:create] || false)
90
+ else
91
+ { error: "Unsupported action: #{action}. Supported actions are: #{ACTIONS.join(', ')}" }
92
+ end
93
+ rescue StandardError => e
94
+ @logger.error("GitTool execution failed: #{e.message}")
95
+ { error: e.message }
96
+ end
97
+
98
+ private
99
+
100
+ # @raise [ArgumentError]
101
+ def require_param!(name, value)
102
+ raise ArgumentError, "#{name} param is required for this action" if value.nil?
103
+
104
+ true
105
+ end
106
+
107
+ def status_tool
108
+ @status_tool ||= Git::StatusTool.new(repo_root: @repo_root, logger: @logger)
109
+ end
110
+
111
+ def diff_tool
112
+ @diff_tool ||= Git::DiffTool.new(repo_root: @repo_root, logger: @logger)
113
+ end
114
+
115
+ def log_tool
116
+ @log_tool ||= Git::LogTool.new(repo_root: @repo_root, logger: @logger)
117
+ end
118
+
119
+ def show_tool
120
+ @show_tool ||= Git::ShowTool.new(repo_root: @repo_root, logger: @logger)
121
+ end
122
+
123
+ def blame_tool
124
+ @blame_tool ||= Git::BlameTool.new(repo_root: @repo_root, logger: @logger)
125
+ end
126
+
127
+ def branch_tool
128
+ @branch_tool ||= Git::BranchTool.new(repo_root: @repo_root, logger: @logger)
129
+ end
130
+
131
+ def grep_tool
132
+ @grep_tool ||= Git::GrepTool.new(repo_root: @repo_root, logger: @logger)
133
+ end
134
+
135
+ def add_tool
136
+ @add_tool ||= Git::AddTool.new(repo_root: @repo_root, logger: @logger)
137
+ end
138
+
139
+ def commit_tool
140
+ @commit_tool ||= Git::CommitTool.new(repo_root: @repo_root, logger: @logger)
141
+ end
142
+
143
+ def checkout_tool
144
+ @checkout_tool ||= Git::CheckoutTool.new(repo_root: @repo_root, logger: @logger)
145
+ end
146
+ end
147
+ end
148
+ end
@@ -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