shared_tools 0.4.4 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +211 -7
- data/lib/shared_tools/apply_patch_tool.rb +4 -0
- data/lib/shared_tools/bash_tool.rb +4 -0
- data/lib/shared_tools/bundle_tool.rb +4 -0
- data/lib/shared_tools/command_guard.rb +35 -0
- data/lib/shared_tools/csv_read_tool.rb +4 -0
- data/lib/shared_tools/csv_write_tool.rb +4 -0
- data/lib/shared_tools/data_path.rb +52 -0
- data/lib/shared_tools/diff_tool.rb +4 -0
- data/lib/shared_tools/download_file_tool.rb +4 -0
- data/lib/shared_tools/gem_tool.rb +4 -0
- data/lib/shared_tools/git_tool.rb +5 -0
- data/lib/shared_tools/glob_tool.rb +4 -0
- data/lib/shared_tools/http_request_tool.rb +4 -0
- data/lib/shared_tools/json_query_tool.rb +4 -0
- data/lib/shared_tools/lint_tool.rb +4 -0
- data/lib/shared_tools/mcp/brave_search_client.rb +18 -16
- data/lib/shared_tools/mcp/chart_client.rb +18 -15
- data/lib/shared_tools/mcp/client_support.rb +103 -0
- data/lib/shared_tools/mcp/github_client.rb +23 -19
- data/lib/shared_tools/mcp/hugging_face_client.rb +22 -22
- data/lib/shared_tools/mcp/memory_client.rb +20 -15
- data/lib/shared_tools/mcp/notion_client.rb +21 -19
- data/lib/shared_tools/mcp/playwright_client.rb +19 -15
- data/lib/shared_tools/mcp/sequential_thinking_client.rb +17 -14
- data/lib/shared_tools/mcp/slack_client.rb +30 -25
- data/lib/shared_tools/mcp/tavily_client.rb +21 -21
- data/lib/shared_tools/mcp.rb +32 -41
- data/lib/shared_tools/multi_edit_tool.rb +4 -0
- data/lib/shared_tools/parse_ruby_tool.rb +4 -0
- data/lib/shared_tools/process_kill_tool.rb +4 -0
- data/lib/shared_tools/process_list_tool.rb +4 -0
- data/lib/shared_tools/process_output_tool.rb +4 -0
- data/lib/shared_tools/process_registry.rb +224 -0
- data/lib/shared_tools/process_runner.rb +69 -0
- data/lib/shared_tools/process_start_tool.rb +4 -0
- data/lib/shared_tools/python_tests_tool.rb +4 -0
- data/lib/shared_tools/replace_in_files_tool.rb +4 -0
- data/lib/shared_tools/ruby_outline.rb +207 -0
- data/lib/shared_tools/run_tests_tool.rb +4 -0
- data/lib/shared_tools/text_diff.rb +80 -0
- data/lib/shared_tools/todo_write_tool.rb +4 -0
- data/lib/shared_tools/toml_parser.rb +409 -0
- data/lib/shared_tools/toml_query_tool.rb +4 -0
- data/lib/shared_tools/tools/apply_patch_tool.rb +122 -0
- data/lib/shared_tools/tools/bash_tool.rb +102 -0
- data/lib/shared_tools/tools/browser/click_tool.rb +2 -2
- data/lib/shared_tools/tools/browser/inspect_tool.rb +2 -2
- data/lib/shared_tools/tools/browser/page_inspect_tool.rb +2 -2
- data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +2 -2
- data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +2 -2
- data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +2 -2
- data/lib/shared_tools/tools/browser/visit_tool.rb +2 -2
- data/lib/shared_tools/tools/browser_tool.rb +2 -2
- data/lib/shared_tools/tools/bundle_tool.rb +96 -0
- data/lib/shared_tools/tools/calculator_tool.rb +2 -2
- data/lib/shared_tools/tools/clipboard_tool.rb +2 -2
- data/lib/shared_tools/tools/composite_analysis_tool.rb +2 -2
- data/lib/shared_tools/tools/computer_tool.rb +2 -2
- data/lib/shared_tools/tools/cron_tool.rb +2 -2
- 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 +3 -3
- data/lib/shared_tools/tools/data_science_kit.rb +2 -2
- data/lib/shared_tools/tools/database_query_tool.rb +2 -2
- data/lib/shared_tools/tools/database_tool.rb +2 -2
- data/lib/shared_tools/tools/diff_tool.rb +48 -0
- data/lib/shared_tools/tools/disk/directory_create_tool.rb +2 -2
- data/lib/shared_tools/tools/disk/directory_delete_tool.rb +2 -2
- data/lib/shared_tools/tools/disk/directory_list_tool.rb +2 -2
- data/lib/shared_tools/tools/disk/directory_move_tool.rb +2 -2
- data/lib/shared_tools/tools/disk/file_create_tool.rb +2 -2
- data/lib/shared_tools/tools/disk/file_delete_tool.rb +2 -2
- data/lib/shared_tools/tools/disk/file_move_tool.rb +2 -2
- data/lib/shared_tools/tools/disk/file_read_tool.rb +2 -2
- data/lib/shared_tools/tools/disk/file_replace_tool.rb +2 -2
- data/lib/shared_tools/tools/disk/file_write_tool.rb +2 -2
- data/lib/shared_tools/tools/disk_tool.rb +2 -2
- data/lib/shared_tools/tools/dns_tool.rb +2 -2
- data/lib/shared_tools/tools/doc/docx_reader_tool.rb +2 -2
- data/lib/shared_tools/tools/doc/pdf_reader_tool.rb +2 -2
- data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +2 -2
- data/lib/shared_tools/tools/doc/text_reader_tool.rb +2 -2
- data/lib/shared_tools/tools/doc_tool.rb +2 -2
- data/lib/shared_tools/tools/docker/compose_run_tool.rb +2 -2
- 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 +2 -2
- data/lib/shared_tools/tools/eval/python_eval_tool.rb +2 -2
- data/lib/shared_tools/tools/eval/ruby_eval_tool.rb +2 -2
- data/lib/shared_tools/tools/eval/shell_eval_tool.rb +2 -2
- data/lib/shared_tools/tools/eval_tool.rb +2 -2
- 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 +2 -2
- 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 +182 -51
- data/lib/shared_tools/tools/secure_tool_template.rb +2 -2
- data/lib/shared_tools/tools/system_info_tool.rb +2 -2
- 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 +2 -2
- data/lib/shared_tools/tools/web_fetch_tool.rb +85 -0
- data/lib/shared_tools/tools/workflow_manager_tool.rb +2 -2
- 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 +532 -409
- data/lib/shared_tools/mcp/streamable_http_patch.rb +0 -42
- data/lib/shared_tools/tools/version.rb +0 -7
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module SharedTools
|
|
7
|
+
module Tools
|
|
8
|
+
module Git
|
|
9
|
+
# Shared behavior for the git tools: running git the same hardened way,
|
|
10
|
+
# and mapping its output onto the shared_tools { error: } contract.
|
|
11
|
+
#
|
|
12
|
+
# Hardening matters because git executes commands configured *by the
|
|
13
|
+
# repository* in several places, which turns "read-only" commands into
|
|
14
|
+
# arbitrary code execution when operating on an untrusted checkout:
|
|
15
|
+
# - core.fsmonitor runs a command during `git status`
|
|
16
|
+
# - diff.external / textconv run commands during `git diff`
|
|
17
|
+
# core.fsmonitor is disabled via `-c`, and the diff/show tools pass
|
|
18
|
+
# --no-ext-diff --no-textconv. The pager and credential prompts are
|
|
19
|
+
# also disabled so a call can never hang waiting on a TTY.
|
|
20
|
+
module Helpers
|
|
21
|
+
GIT_ENV = { "GIT_PAGER" => "cat", "GIT_TERMINAL_PROMPT" => "0" }.freeze
|
|
22
|
+
# A ref/branch the model supplies: no leading dash (option injection),
|
|
23
|
+
# and only the characters git refs legitimately use.
|
|
24
|
+
REF_RE = %r{\A[A-Za-z0-9][\w./\-]*\z}
|
|
25
|
+
|
|
26
|
+
# @param args [Array<String>]
|
|
27
|
+
# @param repo_root [String]
|
|
28
|
+
# @return [Array(String, String, Process::Status)] stdout, stderr, status
|
|
29
|
+
def run_git(*args, repo_root:)
|
|
30
|
+
Open3.capture3(GIT_ENV, "git", "-c", "core.fsmonitor=", "-C", repo_root.to_s, "--no-pager", *args)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Maps a finished git run onto the shared_tools return contract.
|
|
34
|
+
#
|
|
35
|
+
# @return [String, Hash] stdout on success, or an { error:, code: } hash
|
|
36
|
+
def git_result(out, err, status)
|
|
37
|
+
return out if status.success?
|
|
38
|
+
|
|
39
|
+
message = (err.to_s.empty? ? out.to_s : err.to_s).strip
|
|
40
|
+
code = message.match?(/not a git repository/i) ? :not_a_repo : :git_error
|
|
41
|
+
{ error: message.empty? ? "git failed with exit code #{status.exitstatus}" : "git failed: #{message}", code: code }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param ref [String]
|
|
45
|
+
# @return [Boolean]
|
|
46
|
+
def valid_ref?(ref)
|
|
47
|
+
ref.to_s.match?(REF_RE)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Resolve a path param to a repo-relative path, rejecting jail escapes.
|
|
51
|
+
# Returns nil for a blank path.
|
|
52
|
+
#
|
|
53
|
+
# @param path [String, nil]
|
|
54
|
+
# @param repo_root [String]
|
|
55
|
+
#
|
|
56
|
+
# @raise [SecurityError] if the path escapes repo_root
|
|
57
|
+
# @return [String, nil]
|
|
58
|
+
def repo_relative(path, repo_root:)
|
|
59
|
+
return nil if path.nil? || path.to_s.strip.empty?
|
|
60
|
+
|
|
61
|
+
root = Pathname.new(File.expand_path(repo_root))
|
|
62
|
+
resolved = (root + path).cleanpath
|
|
63
|
+
raise SecurityError, "path escapes repo_root: #{path}" unless resolved.ascend.any? { |ancestor| ancestor == root }
|
|
64
|
+
|
|
65
|
+
resolved.relative_path_from(root).to_s
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "helpers"
|
|
4
|
+
|
|
5
|
+
module SharedTools
|
|
6
|
+
module Tools
|
|
7
|
+
module Git
|
|
8
|
+
# Read-only. Shows recent commit history for the repo at repo_root.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# tool = SharedTools::Tools::Git::LogTool.new
|
|
12
|
+
# tool.execute(count: 10)
|
|
13
|
+
class LogTool < ::RubyLLM::Tool
|
|
14
|
+
include Helpers
|
|
15
|
+
|
|
16
|
+
DEFAULT_COUNT = 20
|
|
17
|
+
MAX_COUNT = 200
|
|
18
|
+
FORMAT = "%h %ad %an: %s"
|
|
19
|
+
|
|
20
|
+
def self.tool_name = 'git_log'
|
|
21
|
+
|
|
22
|
+
description "Show recent git commit history for the repository (hash, date, author, " \
|
|
23
|
+
"subject). Optionally limit the count or scope to a path. Read-only."
|
|
24
|
+
|
|
25
|
+
parameters do
|
|
26
|
+
integer :count, description: "Number of commits to show (default #{DEFAULT_COUNT}, max #{MAX_COUNT}).", required: false
|
|
27
|
+
string :path, description: "Limit history to commits touching this path, relative to the repo root. Optional.", required: false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param repo_root [String] optional, defaults to the current directory
|
|
31
|
+
# @param logger [Logger] optional logger
|
|
32
|
+
def initialize(repo_root: nil, logger: nil)
|
|
33
|
+
@repo_root = repo_root || Dir.pwd
|
|
34
|
+
@logger = logger || RubyLLM.logger
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param count [Integer]
|
|
38
|
+
# @param path [String, nil]
|
|
39
|
+
#
|
|
40
|
+
# @return [String, Hash]
|
|
41
|
+
def execute(count: DEFAULT_COUNT, path: nil)
|
|
42
|
+
@logger.info("#{self.class.name}#execute count=#{count} path=#{path.inspect}")
|
|
43
|
+
|
|
44
|
+
n = count.to_i
|
|
45
|
+
n = DEFAULT_COUNT if n <= 0
|
|
46
|
+
n = MAX_COUNT if n > MAX_COUNT
|
|
47
|
+
|
|
48
|
+
rel = repo_relative(path, repo_root: @repo_root)
|
|
49
|
+
args = ["log", "--max-count=#{n}", "--date=short", "--pretty=format:#{FORMAT}"]
|
|
50
|
+
args += ["--", rel] if rel
|
|
51
|
+
|
|
52
|
+
out, err, status = run_git(*args, repo_root: @repo_root)
|
|
53
|
+
result = git_result(out, err, status)
|
|
54
|
+
if result.is_a?(Hash)
|
|
55
|
+
return "no commits" if result[:error].to_s.match?(/does not have any commits yet|bad default revision/i)
|
|
56
|
+
|
|
57
|
+
return result
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
result.strip.empty? ? "no commits" : result
|
|
61
|
+
rescue SecurityError => e
|
|
62
|
+
@logger.error("#{self.class.name} path denied: #{e.message}")
|
|
63
|
+
{ error: e.message }
|
|
64
|
+
rescue => e
|
|
65
|
+
@logger.error("#{self.class.name} failed: #{e.message}")
|
|
66
|
+
{ error: e.message }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "helpers"
|
|
4
|
+
|
|
5
|
+
module SharedTools
|
|
6
|
+
module Tools
|
|
7
|
+
module Git
|
|
8
|
+
# Read-only. Shows a commit (message + diff) or the contents of a file
|
|
9
|
+
# at a given ref. External diff drivers and textconv are disabled.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# tool = SharedTools::Tools::Git::ShowTool.new
|
|
13
|
+
# tool.execute
|
|
14
|
+
# tool.execute(ref: "HEAD~1", path: "lib/foo.rb")
|
|
15
|
+
class ShowTool < ::RubyLLM::Tool
|
|
16
|
+
include Helpers
|
|
17
|
+
|
|
18
|
+
def self.tool_name = 'git_show'
|
|
19
|
+
|
|
20
|
+
description "Show a git object in the repository. With no path, shows the commit at ref " \
|
|
21
|
+
"(message and diff). With a path, shows that file's contents as of ref. Defaults " \
|
|
22
|
+
"to HEAD. Read-only."
|
|
23
|
+
|
|
24
|
+
parameters do
|
|
25
|
+
string :ref, description: "Commit/branch/tag to show. Default HEAD.", required: false
|
|
26
|
+
string :path, description: "If given, show this file's contents at ref instead of the commit. Optional.", required: false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param repo_root [String] optional, defaults to the current directory
|
|
30
|
+
# @param logger [Logger] optional logger
|
|
31
|
+
def initialize(repo_root: nil, logger: nil)
|
|
32
|
+
@repo_root = repo_root || Dir.pwd
|
|
33
|
+
@logger = logger || RubyLLM.logger
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param ref [String]
|
|
37
|
+
# @param path [String, nil]
|
|
38
|
+
#
|
|
39
|
+
# @return [String, Hash]
|
|
40
|
+
def execute(ref: "HEAD", path: nil)
|
|
41
|
+
@logger.info("#{self.class.name}#execute ref=#{ref.inspect} path=#{path.inspect}")
|
|
42
|
+
|
|
43
|
+
ref = ref.to_s.strip
|
|
44
|
+
ref = "HEAD" if ref.empty?
|
|
45
|
+
return { error: "invalid ref: #{ref.inspect}" } unless valid_ref?(ref)
|
|
46
|
+
|
|
47
|
+
rel = repo_relative(path, repo_root: @repo_root)
|
|
48
|
+
args = if rel
|
|
49
|
+
["show", "#{ref}:#{rel}"]
|
|
50
|
+
else
|
|
51
|
+
["show", "--no-ext-diff", "--no-textconv", "--stat", "--patch", ref]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
out, err, status = run_git(*args, repo_root: @repo_root)
|
|
55
|
+
git_result(out, err, status)
|
|
56
|
+
rescue SecurityError => e
|
|
57
|
+
@logger.error("#{self.class.name} path denied: #{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
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "helpers"
|
|
4
|
+
|
|
5
|
+
module SharedTools
|
|
6
|
+
module Tools
|
|
7
|
+
module Git
|
|
8
|
+
# Read-only. Shows the working-tree status of the repo at repo_root
|
|
9
|
+
# (current branch plus staged/unstaged/untracked changes).
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# tool = SharedTools::Tools::Git::StatusTool.new
|
|
13
|
+
# tool.execute
|
|
14
|
+
class StatusTool < ::RubyLLM::Tool
|
|
15
|
+
include Helpers
|
|
16
|
+
|
|
17
|
+
def self.tool_name = 'git_status'
|
|
18
|
+
|
|
19
|
+
description "Show the git working-tree status of the repository: current branch and " \
|
|
20
|
+
"staged, unstaged, and untracked changes. Read-only."
|
|
21
|
+
|
|
22
|
+
# @param repo_root [String] optional, defaults to the current directory
|
|
23
|
+
# @param logger [Logger] optional logger
|
|
24
|
+
def initialize(repo_root: nil, logger: nil)
|
|
25
|
+
@repo_root = repo_root || Dir.pwd
|
|
26
|
+
@logger = logger || RubyLLM.logger
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [String, Hash]
|
|
30
|
+
def execute
|
|
31
|
+
@logger.info("#{self.class.name}#execute repo_root=#{@repo_root}")
|
|
32
|
+
|
|
33
|
+
out, err, status = run_git("status", "--short", "--branch", repo_root: @repo_root)
|
|
34
|
+
result = git_result(out, err, status)
|
|
35
|
+
return result if result.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
changes = result.each_line.reject { |line| line.start_with?("##") }
|
|
38
|
+
changes.empty? ? "working tree clean" : result
|
|
39
|
+
rescue => e
|
|
40
|
+
@logger.error("#{self.class.name} failed: #{e.message}")
|
|
41
|
+
{ error: e.message }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Collection loader for all git tools
|
|
4
|
+
# Usage: require 'shared_tools/tools/git'
|
|
5
|
+
|
|
6
|
+
require 'shared_tools'
|
|
7
|
+
|
|
8
|
+
require_relative 'git/helpers'
|
|
9
|
+
require_relative 'git/status_tool'
|
|
10
|
+
require_relative 'git/diff_tool'
|
|
11
|
+
require_relative 'git/log_tool'
|
|
12
|
+
require_relative 'git/show_tool'
|
|
13
|
+
require_relative 'git/blame_tool'
|
|
14
|
+
require_relative 'git/branch_tool'
|
|
15
|
+
require_relative 'git/grep_tool'
|
|
16
|
+
require_relative 'git/add_tool'
|
|
17
|
+
require_relative 'git/commit_tool'
|
|
18
|
+
require_relative 'git/checkout_tool'
|
|
@@ -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.tool_name = 'git'
|
|
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.tool_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
|