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
|
@@ -17,7 +17,7 @@ module SharedTools
|
|
|
17
17
|
|
|
18
18
|
description "Replaces a specific string in a file (old_text => new_text)."
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
parameters do
|
|
21
21
|
string :old_text, description: "the old text (e.g. `puts 'ABC'`)"
|
|
22
22
|
string :new_text, description: "the new text (e.g. `puts 'DEF'`)"
|
|
23
23
|
string :path, description: "a path (e.g. `./main.rb`)"
|
|
@@ -13,7 +13,7 @@ module SharedTools
|
|
|
13
13
|
|
|
14
14
|
description "Writes the contents of a file."
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
parameters do
|
|
17
17
|
string :path, description: "a path for the file (e.g. `./main.rb`)"
|
|
18
18
|
string :text, description: "the text to write to the file (e.g. `puts 'Hello World'`)"
|
|
19
19
|
end
|
|
@@ -40,7 +40,7 @@ module SharedTools
|
|
|
40
40
|
- 'whois' — Query the WHOIS database for a domain name or IP address
|
|
41
41
|
DESC
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
parameters do
|
|
44
44
|
string :action, description: <<~DESC.strip
|
|
45
45
|
The DNS or network operation to perform. One of:
|
|
46
46
|
- 'a' — Look up IPv4 A records for a hostname. Returns the IPv4 addresses
|
|
@@ -20,7 +20,7 @@ module SharedTools
|
|
|
20
20
|
|
|
21
21
|
description "Read the text content of a Microsoft Word (.docx) document."
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
parameters do
|
|
24
24
|
string :doc_path, description: "Path to the .docx file."
|
|
25
25
|
|
|
26
26
|
string :paragraph_range, description: <<~DESC.strip, required: false
|
|
@@ -18,7 +18,7 @@ module SharedTools
|
|
|
18
18
|
|
|
19
19
|
description "Read the text of any set of pages from a PDF document."
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
parameters do
|
|
22
22
|
string :page_numbers, description: 'Comma-separated page numbers (first page: 1). (e.g. "12, 14, 15")'
|
|
23
23
|
string :doc_path, description: "Path to the PDF document."
|
|
24
24
|
end
|
|
@@ -27,7 +27,7 @@ module SharedTools
|
|
|
27
27
|
|
|
28
28
|
SUPPORTED_FORMATS = %w[.csv .xlsx .ods .xlsm].freeze
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
parameters do
|
|
31
31
|
string :doc_path, description: <<~DESC.strip
|
|
32
32
|
Path to the spreadsheet file. Supported formats:
|
|
33
33
|
- .csv — Comma-separated values (plain text, no gem beyond roo required)
|
|
@@ -85,7 +85,7 @@ module SharedTools
|
|
|
85
85
|
{"action": "#{Action::SPREADSHEET_READ}", "doc_path": "./report.xlsx", "row_range": "2-50", "headers": false}
|
|
86
86
|
TEXT
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
parameters do
|
|
89
89
|
string :action, description: <<~TEXT.strip
|
|
90
90
|
The document action to perform. Options:
|
|
91
91
|
* `#{Action::TEXT_READ}`: Read a plain text file
|
|
@@ -13,7 +13,7 @@ module SharedTools
|
|
|
13
13
|
|
|
14
14
|
description "Runs a command via Docker with arguments on the project (e.g. `rspec spec/main_spec.rb`)."
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
parameters do
|
|
17
17
|
string :service, description: "The service to run the command on (e.g. `app`).", required: false
|
|
18
18
|
string :command, description: "The command to run (e.g. `rspec`)."
|
|
19
19
|
array :args, of: :string, description: "The arguments for the command.", required: false
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require_relative "../../shared_tools"
|
|
6
|
+
require_relative "http_helpers"
|
|
7
|
+
|
|
8
|
+
module SharedTools
|
|
9
|
+
module Tools
|
|
10
|
+
# Downloads a URL to a file within root. The request goes through
|
|
11
|
+
# UrlGuard (so it can't be pointed at internal/loopback/metadata
|
|
12
|
+
# addresses), follows redirects safely, and is capped at
|
|
13
|
+
# HttpHelpers::MAX_FETCH_BYTES. The destination path is confined to
|
|
14
|
+
# root. Mutating — requires user authorization (see
|
|
15
|
+
# SharedTools.execute?).
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# tool = SharedTools::Tools::DownloadFileTool.new(root: "./downloads")
|
|
19
|
+
# tool.execute(url: "https://example.com/file.zip", path: "file.zip")
|
|
20
|
+
class DownloadFileTool < ::RubyLLM::Tool
|
|
21
|
+
include HttpHelpers
|
|
22
|
+
|
|
23
|
+
def self.name = 'download_file'
|
|
24
|
+
|
|
25
|
+
description "Download an http/https URL to a file within root. SSRF-protected and " \
|
|
26
|
+
"size-capped. Use for fetching an asset or release to disk (web_fetch returns text " \
|
|
27
|
+
"instead). Overwrites an existing file."
|
|
28
|
+
|
|
29
|
+
parameters do
|
|
30
|
+
string :url, description: "The http/https URL to download."
|
|
31
|
+
string :path, description: "Destination path, relative to root."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param root [String] optional, defaults to the current directory
|
|
35
|
+
# @param logger [Logger] optional logger
|
|
36
|
+
def initialize(root: nil, logger: nil)
|
|
37
|
+
@root = root || Dir.pwd
|
|
38
|
+
@logger = logger || RubyLLM.logger
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param url [String]
|
|
42
|
+
# @param path [String]
|
|
43
|
+
#
|
|
44
|
+
# @return [String, Hash]
|
|
45
|
+
def execute(url:, path:)
|
|
46
|
+
@logger.info("#{self.class.name}#execute url=#{url.inspect} path=#{path.inspect}")
|
|
47
|
+
|
|
48
|
+
real = resolve!(path)
|
|
49
|
+
return { error: "path is a directory: #{path}" } if File.directory?(real)
|
|
50
|
+
|
|
51
|
+
allowed = SharedTools.execute?(tool: self.class.to_s, stuff: "Download #{url} to #{path}")
|
|
52
|
+
unless allowed
|
|
53
|
+
@logger.warn("User declined to download #{url}")
|
|
54
|
+
return { error: "User declined to download the file" }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
response = guarded_get(url)
|
|
58
|
+
return { error: "server returned HTTP #{response.status} for #{response.final_url}" } if response.status >= 400
|
|
59
|
+
|
|
60
|
+
body = response.body.to_s
|
|
61
|
+
FileUtils.mkdir_p(File.dirname(real))
|
|
62
|
+
File.binwrite(real, body)
|
|
63
|
+
|
|
64
|
+
"Downloaded #{body.bytesize} bytes from #{response.final_url} to #{path}"
|
|
65
|
+
rescue SecurityError => e
|
|
66
|
+
@logger.error("#{self.class.name} path denied: #{e.message}")
|
|
67
|
+
{ error: e.message }
|
|
68
|
+
rescue SharedTools::UrlGuard::Blocked => e
|
|
69
|
+
@logger.error("#{self.class.name} url blocked: #{e.message}")
|
|
70
|
+
{ error: e.message }
|
|
71
|
+
rescue HttpHelpers::FetchError => e
|
|
72
|
+
@logger.error("#{self.class.name} download failed: #{e.message}")
|
|
73
|
+
{ error: "download failed: #{e.message}" }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def resolve!(path)
|
|
79
|
+
root = Pathname.new(File.expand_path(@root))
|
|
80
|
+
resolved = (root + path).cleanpath
|
|
81
|
+
raise SecurityError, "path escapes root: #{path}" unless resolved.ascend.any? { |ancestor| ancestor == root }
|
|
82
|
+
|
|
83
|
+
resolved.to_s
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -24,8 +24,8 @@ module Tools
|
|
|
24
24
|
description <<~DESCRIPTION
|
|
25
25
|
Enables a tool based on the request.
|
|
26
26
|
DESCRIPTION
|
|
27
|
-
|
|
28
|
-
#
|
|
27
|
+
parameter :tool, description: 'Tool name to enable'
|
|
28
|
+
# parameter :params, description: 'JSON formatted parameters', required: false
|
|
29
29
|
|
|
30
30
|
def initialize(agent)
|
|
31
31
|
@agent = agent
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "open3"
|
|
3
4
|
|
|
4
5
|
module SharedTools
|
|
5
6
|
module Tools
|
|
@@ -7,13 +8,18 @@ module SharedTools
|
|
|
7
8
|
# @example
|
|
8
9
|
# tool = SharedTools::Tools::Eval::ShellEvalTool.new
|
|
9
10
|
# tool.execute(command: "ls -la")
|
|
11
|
+
# tool.execute(command: "ls -la", workdir: "/tmp")
|
|
10
12
|
class ShellEvalTool < ::RubyLLM::Tool
|
|
13
|
+
STDOUT_MAX = 5000
|
|
14
|
+
STDERR_MAX = 2000
|
|
15
|
+
|
|
11
16
|
def self.name = 'eval_shell'
|
|
12
17
|
|
|
13
18
|
description "Execute a shell command safely and return the result."
|
|
14
19
|
|
|
15
|
-
|
|
20
|
+
parameters do
|
|
16
21
|
string :command, description: "The shell command to execute"
|
|
22
|
+
string :workdir, description: "Working directory for the command (default: current directory)", required: false
|
|
17
23
|
end
|
|
18
24
|
|
|
19
25
|
# @param logger [Logger] optional logger
|
|
@@ -22,9 +28,10 @@ module SharedTools
|
|
|
22
28
|
end
|
|
23
29
|
|
|
24
30
|
# @param command [String] shell command to execute
|
|
31
|
+
# @param workdir [String, nil] working directory for the command
|
|
25
32
|
#
|
|
26
33
|
# @return [Hash] execution result
|
|
27
|
-
def execute(command:)
|
|
34
|
+
def execute(command:, workdir: nil)
|
|
28
35
|
@logger.info("Requesting permission to execute command: '#{command}'")
|
|
29
36
|
|
|
30
37
|
if command.strip.empty?
|
|
@@ -33,7 +40,12 @@ module SharedTools
|
|
|
33
40
|
return { error: error_msg }
|
|
34
41
|
end
|
|
35
42
|
|
|
36
|
-
|
|
43
|
+
if workdir
|
|
44
|
+
unless File.directory?(workdir)
|
|
45
|
+
return { error: "Working directory not found: #{workdir}" }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
37
49
|
allowed = SharedTools.execute?(tool: self.class.to_s, stuff: command)
|
|
38
50
|
|
|
39
51
|
unless allowed
|
|
@@ -43,9 +55,11 @@ module SharedTools
|
|
|
43
55
|
|
|
44
56
|
@logger.info("Executing command: '#{command}'")
|
|
45
57
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
58
|
+
opts = workdir ? { chdir: workdir } : {}
|
|
59
|
+
stdout, stderr, status = Open3.capture3(command, **opts)
|
|
60
|
+
|
|
61
|
+
stdout = truncate(stdout, STDOUT_MAX)
|
|
62
|
+
stderr = truncate(stderr, STDERR_MAX)
|
|
49
63
|
|
|
50
64
|
if status.success?
|
|
51
65
|
@logger.debug("Command execution completed successfully with #{stdout.bytesize} bytes of output")
|
|
@@ -58,6 +72,13 @@ module SharedTools
|
|
|
58
72
|
@logger.error("Command execution failed: #{e.message}")
|
|
59
73
|
{ error: e.message }
|
|
60
74
|
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def truncate(str, max)
|
|
79
|
+
return str if str.length <= max
|
|
80
|
+
str.slice(0, max) + "\n[truncated]"
|
|
81
|
+
end
|
|
61
82
|
end
|
|
62
83
|
end
|
|
63
84
|
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require_relative "../../shared_tools"
|
|
7
|
+
|
|
8
|
+
module SharedTools
|
|
9
|
+
module Tools
|
|
10
|
+
# Read-only RubyGems.org metadata lookup. The host is fixed and all user
|
|
11
|
+
# input is URL-encoded into the path/query (and gem names are validated),
|
|
12
|
+
# so there is no arbitrary-URL / SSRF surface here.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# tool = SharedTools::Tools::GemTool.new
|
|
16
|
+
# tool.execute(name: "ruby_llm")
|
|
17
|
+
# tool.execute(name: "ruby_llm", action: "version")
|
|
18
|
+
# tool.execute(name: "sqlite3", action: "search")
|
|
19
|
+
class GemTool < ::RubyLLM::Tool
|
|
20
|
+
class NotFound < StandardError; end
|
|
21
|
+
class HttpError < StandardError; end
|
|
22
|
+
|
|
23
|
+
HTTP_TIMEOUT = 10
|
|
24
|
+
USER_AGENT = "SharedTools/#{SharedTools::VERSION} (+https://github.com/madbomber/shared_tools)"
|
|
25
|
+
ACTIONS = %w[info version dependencies search].freeze
|
|
26
|
+
NAME_RE = /\A[A-Za-z0-9_.-]+\z/
|
|
27
|
+
HOST = "https://rubygems.org"
|
|
28
|
+
|
|
29
|
+
def self.name = 'gem'
|
|
30
|
+
|
|
31
|
+
description "Look up RubyGems.org metadata for a gem (read-only). Actions: info (summary), " \
|
|
32
|
+
"version (latest version), dependencies (runtime deps), search (find gems by " \
|
|
33
|
+
"query). For 'search', pass the query as name."
|
|
34
|
+
|
|
35
|
+
parameters do
|
|
36
|
+
string :name, description: "Gem name, or — for action 'search' — the search query."
|
|
37
|
+
string :action, description: "One of: info, version, dependencies, search. Default info.", required: false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param logger [Logger] optional logger
|
|
41
|
+
def initialize(logger: nil)
|
|
42
|
+
@logger = logger || RubyLLM.logger
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @param name [String]
|
|
46
|
+
# @param action [String]
|
|
47
|
+
#
|
|
48
|
+
# @return [String, Hash]
|
|
49
|
+
def execute(name:, action: "info")
|
|
50
|
+
@logger.info("#{self.class.name}#execute name=#{name.inspect} action=#{action.inspect}")
|
|
51
|
+
|
|
52
|
+
act = normalize_action(action)
|
|
53
|
+
return { error: "unknown action: #{act} (use #{ACTIONS.join(', ')})" } unless ACTIONS.include?(act)
|
|
54
|
+
return search(name) if act == "search"
|
|
55
|
+
|
|
56
|
+
gem = name.to_s.strip
|
|
57
|
+
return { error: "invalid gem name: #{gem.inspect}" } unless gem.match?(NAME_RE)
|
|
58
|
+
|
|
59
|
+
case act
|
|
60
|
+
when "version" then version(gem)
|
|
61
|
+
when "dependencies" then dependencies(gem)
|
|
62
|
+
else info(gem)
|
|
63
|
+
end
|
|
64
|
+
rescue NotFound
|
|
65
|
+
@logger.error("#{self.class.name}: gem not found: #{name}")
|
|
66
|
+
{ error: "gem not found: #{name}" }
|
|
67
|
+
rescue HttpError => e
|
|
68
|
+
@logger.error("#{self.class.name}: rubygems.org request failed: #{e.message}")
|
|
69
|
+
{ error: "rubygems.org request failed: #{e.message}" }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def normalize_action(action)
|
|
75
|
+
a = action.to_s.strip.downcase
|
|
76
|
+
a.empty? ? "info" : a
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def info(gem)
|
|
80
|
+
data = get_json("#{HOST}/api/v1/gems/#{enc(gem)}.json")
|
|
81
|
+
lines = ["#{data['name']} #{data['version']}"]
|
|
82
|
+
lines << data["info"].to_s.strip unless data["info"].to_s.strip.empty?
|
|
83
|
+
lines << "homepage: #{data['homepage_uri']}" if data["homepage_uri"]
|
|
84
|
+
lines << "licenses: #{Array(data['licenses']).join(', ')}" if data["licenses"]
|
|
85
|
+
lines << "downloads: #{data['downloads']}" if data["downloads"]
|
|
86
|
+
runtime = data.dig("dependencies", "runtime") || []
|
|
87
|
+
unless runtime.empty?
|
|
88
|
+
lines << "runtime deps: #{runtime.map { |d| "#{d['name']} #{d['requirements']}" }.join('; ')}"
|
|
89
|
+
end
|
|
90
|
+
lines.join("\n")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def version(gem)
|
|
94
|
+
data = get_json("#{HOST}/api/v1/versions/#{enc(gem)}/latest.json")
|
|
95
|
+
v = data["version"]
|
|
96
|
+
return { error: "gem not found: #{gem}" } if v.nil? || v == "unknown"
|
|
97
|
+
|
|
98
|
+
"#{gem} #{v}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def dependencies(gem)
|
|
102
|
+
data = get_json("#{HOST}/api/v1/gems/#{enc(gem)}.json")
|
|
103
|
+
runtime = data.dig("dependencies", "runtime") || []
|
|
104
|
+
dev = data.dig("dependencies", "development") || []
|
|
105
|
+
return "#{gem} #{data['version']} has no runtime dependencies" if runtime.empty?
|
|
106
|
+
|
|
107
|
+
body = +"#{gem} #{data['version']} runtime dependencies:\n"
|
|
108
|
+
runtime.each { |d| body << " #{d['name']} #{d['requirements']}\n" }
|
|
109
|
+
body << "(+#{dev.size} development deps)" unless dev.empty?
|
|
110
|
+
body
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def search(query)
|
|
114
|
+
q = query.to_s.strip
|
|
115
|
+
return { error: "empty search query" } if q.empty?
|
|
116
|
+
|
|
117
|
+
results = get_json("#{HOST}/api/v1/search.json?query=#{enc(q)}")
|
|
118
|
+
return "no gems found for #{q.inspect}" unless results.is_a?(Array) && results.any?
|
|
119
|
+
|
|
120
|
+
body = +"#{results.size} result#{results.size == 1 ? '' : 's'} for #{q.inspect}:\n"
|
|
121
|
+
results.first(20).each do |g|
|
|
122
|
+
summary = g["info"].to_s.split("\n").first
|
|
123
|
+
body << " #{g['name']} #{g['version']} — #{summary}\n"
|
|
124
|
+
end
|
|
125
|
+
body
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def enc(str)
|
|
129
|
+
URI.encode_www_form_component(str)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Seam for tests. Performs a GET and parses JSON, mapping transport and
|
|
133
|
+
# not-found conditions onto the tool's error classes.
|
|
134
|
+
def get_json(url)
|
|
135
|
+
uri = URI.parse(url)
|
|
136
|
+
req = Net::HTTP::Get.new(uri)
|
|
137
|
+
req["User-Agent"] = USER_AGENT
|
|
138
|
+
req["Accept"] = "application/json"
|
|
139
|
+
|
|
140
|
+
res = Net::HTTP.start(uri.host, uri.port,
|
|
141
|
+
use_ssl: uri.scheme == "https",
|
|
142
|
+
open_timeout: HTTP_TIMEOUT,
|
|
143
|
+
read_timeout: HTTP_TIMEOUT) { |http| http.request(req) }
|
|
144
|
+
|
|
145
|
+
case res
|
|
146
|
+
when Net::HTTPSuccess then JSON.parse(res.body)
|
|
147
|
+
when Net::HTTPNotFound then raise NotFound
|
|
148
|
+
else raise HttpError, "HTTP #{res.code}"
|
|
149
|
+
end
|
|
150
|
+
rescue JSON::ParserError => e
|
|
151
|
+
raise HttpError, "invalid JSON (#{e.message})"
|
|
152
|
+
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
|
|
153
|
+
raise HttpError, e.message
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "helpers"
|
|
4
|
+
|
|
5
|
+
module SharedTools
|
|
6
|
+
module Tools
|
|
7
|
+
module Git
|
|
8
|
+
# Mutating. Stages changes in the repo at repo_root. Requires user
|
|
9
|
+
# authorization (see SharedTools.execute?).
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# tool = SharedTools::Tools::Git::AddTool.new
|
|
13
|
+
# tool.execute(paths: ["lib/foo.rb"])
|
|
14
|
+
# tool.execute(all: true)
|
|
15
|
+
class AddTool < ::RubyLLM::Tool
|
|
16
|
+
include Helpers
|
|
17
|
+
|
|
18
|
+
def self.name = 'git_add'
|
|
19
|
+
|
|
20
|
+
description "Stage changes in the repository. Pass specific paths, or set all to stage " \
|
|
21
|
+
"every change (git add -A)."
|
|
22
|
+
|
|
23
|
+
parameters do
|
|
24
|
+
array :paths, of: :string, description: "Paths to stage, relative to the repo root.", required: false
|
|
25
|
+
boolean :all, description: "Stage all changes (git add -A) instead of specific paths. Default false.", required: false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param repo_root [String] optional, defaults to the current directory
|
|
29
|
+
# @param logger [Logger] optional logger
|
|
30
|
+
def initialize(repo_root: nil, logger: nil)
|
|
31
|
+
@repo_root = repo_root || Dir.pwd
|
|
32
|
+
@logger = logger || RubyLLM.logger
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param paths [Array<String>, nil]
|
|
36
|
+
# @param all [Boolean]
|
|
37
|
+
#
|
|
38
|
+
# @return [String, Hash]
|
|
39
|
+
def execute(paths: nil, all: false)
|
|
40
|
+
@logger.info("#{self.class.name}#execute paths=#{paths.inspect} all=#{all}")
|
|
41
|
+
|
|
42
|
+
rels = Array(paths).filter_map { |p| repo_relative(p, repo_root: @repo_root) }
|
|
43
|
+
|
|
44
|
+
if all
|
|
45
|
+
args = ["add", "-A"]
|
|
46
|
+
elsif rels.empty?
|
|
47
|
+
return { error: "provide paths or set all: true" }
|
|
48
|
+
else
|
|
49
|
+
args = ["add", "--", *rels]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
allowed = SharedTools.execute?(tool: self.class.to_s, stuff: args.join(" "))
|
|
53
|
+
unless allowed
|
|
54
|
+
@logger.warn("User declined to stage changes")
|
|
55
|
+
return { error: "User declined to stage changes" }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
out, err, status = run_git(*args, repo_root: @repo_root)
|
|
59
|
+
result = git_result(out, err, status)
|
|
60
|
+
return result if result.is_a?(Hash)
|
|
61
|
+
|
|
62
|
+
all ? "Staged all changes" : "Staged: #{rels.join(', ')}"
|
|
63
|
+
rescue SecurityError => e
|
|
64
|
+
@logger.error("#{self.class.name} path denied: #{e.message}")
|
|
65
|
+
{ error: e.message }
|
|
66
|
+
rescue => e
|
|
67
|
+
@logger.error("#{self.class.name} failed: #{e.message}")
|
|
68
|
+
{ error: e.message }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "helpers"
|
|
4
|
+
|
|
5
|
+
module SharedTools
|
|
6
|
+
module Tools
|
|
7
|
+
module Git
|
|
8
|
+
# Read-only. Shows line-by-line authorship (commit, author, date) for a
|
|
9
|
+
# file in the repo at repo_root.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# tool = SharedTools::Tools::Git::BlameTool.new
|
|
13
|
+
# tool.execute(path: "lib/foo.rb", start_line: 10, end_line: 20)
|
|
14
|
+
class BlameTool < ::RubyLLM::Tool
|
|
15
|
+
include Helpers
|
|
16
|
+
|
|
17
|
+
def self.name = 'git_blame'
|
|
18
|
+
|
|
19
|
+
description "Show git blame for a file in the repository: which commit, author, and date " \
|
|
20
|
+
"last touched each line. Optionally limit to a line range. Read-only."
|
|
21
|
+
|
|
22
|
+
parameters do
|
|
23
|
+
string :path, description: "File to blame, relative to the repo root."
|
|
24
|
+
integer :start_line, description: "First line of the range to blame. Optional.", required: false
|
|
25
|
+
integer :end_line, description: "Last line of the range to blame. Optional.", required: false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param repo_root [String] optional, defaults to the current directory
|
|
29
|
+
# @param logger [Logger] optional logger
|
|
30
|
+
def initialize(repo_root: nil, logger: nil)
|
|
31
|
+
@repo_root = repo_root || Dir.pwd
|
|
32
|
+
@logger = logger || RubyLLM.logger
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param path [String]
|
|
36
|
+
# @param start_line [Integer, nil]
|
|
37
|
+
# @param end_line [Integer, nil]
|
|
38
|
+
#
|
|
39
|
+
# @return [String, Hash]
|
|
40
|
+
def execute(path:, start_line: nil, end_line: nil)
|
|
41
|
+
@logger.info("#{self.class.name}#execute path=#{path.inspect} start_line=#{start_line.inspect} end_line=#{end_line.inspect}")
|
|
42
|
+
|
|
43
|
+
rel = repo_relative(path, repo_root: @repo_root)
|
|
44
|
+
return { error: "path must be provided" } if rel.nil?
|
|
45
|
+
|
|
46
|
+
args = ["blame", "--date=short"]
|
|
47
|
+
if (range = line_range(start_line, end_line))
|
|
48
|
+
args += ["-L", range]
|
|
49
|
+
end
|
|
50
|
+
args += ["--", rel]
|
|
51
|
+
|
|
52
|
+
out, err, status = run_git(*args, repo_root: @repo_root)
|
|
53
|
+
result = git_result(out, err, status)
|
|
54
|
+
return result if result.is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
result.strip.empty? ? "no blame output (empty file?)" : result
|
|
57
|
+
rescue SecurityError => e
|
|
58
|
+
@logger.error("#{self.class.name} path denied: #{e.message}")
|
|
59
|
+
{ error: e.message }
|
|
60
|
+
rescue => e
|
|
61
|
+
@logger.error("#{self.class.name} failed: #{e.message}")
|
|
62
|
+
{ error: e.message }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def line_range(start_line, end_line)
|
|
68
|
+
return nil if start_line.nil? && end_line.nil?
|
|
69
|
+
|
|
70
|
+
first = [start_line.to_i, 1].max
|
|
71
|
+
last = end_line.nil? ? "" : [end_line.to_i, first].max
|
|
72
|
+
"#{first},#{last}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|