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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -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 +12 -2
- 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 +12 -2
- 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 +27 -6
- 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 +222 -0
- 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 +12 -2
- 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 -394
- data/lib/shared_tools/mcp/streamable_http_patch.rb +0 -42
- 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
|