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,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.tool_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.tool_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.tool_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
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "helpers"
|
|
4
|
+
|
|
5
|
+
module SharedTools
|
|
6
|
+
module Tools
|
|
7
|
+
module Git
|
|
8
|
+
# Read-only. Lists the branches of the repo at repo_root, with the
|
|
9
|
+
# current branch marked and each branch's latest commit. Does not
|
|
10
|
+
# create, switch, or delete branches (see Git::CheckoutTool).
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# tool = SharedTools::Tools::Git::BranchTool.new
|
|
14
|
+
# tool.execute(all: true)
|
|
15
|
+
class BranchTool < ::RubyLLM::Tool
|
|
16
|
+
include Helpers
|
|
17
|
+
|
|
18
|
+
def self.tool_name = 'git_branch'
|
|
19
|
+
|
|
20
|
+
description "List the branches of the repository, with the current branch marked (*) and " \
|
|
21
|
+
"each branch's tip commit. Set all true to include remote-tracking branches. Read-only."
|
|
22
|
+
|
|
23
|
+
parameters do
|
|
24
|
+
boolean :all, description: "Include remote-tracking branches. Default false (local only).", required: false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param repo_root [String] optional, defaults to the current directory
|
|
28
|
+
# @param logger [Logger] optional logger
|
|
29
|
+
def initialize(repo_root: nil, logger: nil)
|
|
30
|
+
@repo_root = repo_root || Dir.pwd
|
|
31
|
+
@logger = logger || RubyLLM.logger
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param all [Boolean]
|
|
35
|
+
#
|
|
36
|
+
# @return [String, Hash]
|
|
37
|
+
def execute(all: false)
|
|
38
|
+
@logger.info("#{self.class.name}#execute all=#{all}")
|
|
39
|
+
|
|
40
|
+
args = ["branch", "--no-color", "-vv"]
|
|
41
|
+
args << "-a" if all
|
|
42
|
+
|
|
43
|
+
out, err, status = run_git(*args, repo_root: @repo_root)
|
|
44
|
+
result = git_result(out, err, status)
|
|
45
|
+
return result if result.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
result.strip.empty? ? "no branches yet (no commits?)" : result
|
|
48
|
+
rescue => e
|
|
49
|
+
@logger.error("#{self.class.name} failed: #{e.message}")
|
|
50
|
+
{ error: e.message }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "helpers"
|
|
4
|
+
|
|
5
|
+
module SharedTools
|
|
6
|
+
module Tools
|
|
7
|
+
module Git
|
|
8
|
+
# Mutating. Switches branches or creates a new branch in the repo at
|
|
9
|
+
# repo_root; can change the working tree. The ref is validated (no
|
|
10
|
+
# leading dash, ref-safe characters only) so it can't smuggle git
|
|
11
|
+
# options. Requires user authorization (see SharedTools.execute?).
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# tool = SharedTools::Tools::Git::CheckoutTool.new
|
|
15
|
+
# tool.execute(ref: "main")
|
|
16
|
+
# tool.execute(ref: "feature/x", create: true)
|
|
17
|
+
class CheckoutTool < ::RubyLLM::Tool
|
|
18
|
+
include Helpers
|
|
19
|
+
|
|
20
|
+
def self.tool_name = 'git_checkout'
|
|
21
|
+
|
|
22
|
+
description "Switch to a branch/commit in the repository, or create a new branch with " \
|
|
23
|
+
"create: true. Note this can change the working tree."
|
|
24
|
+
|
|
25
|
+
parameters do
|
|
26
|
+
string :ref, description: "Branch, tag, or commit to switch to (or the new branch name when create is true)."
|
|
27
|
+
boolean :create, description: "Create a new branch named ref before switching to it (git checkout -b). Default false.", 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 ref [String]
|
|
38
|
+
# @param create [Boolean]
|
|
39
|
+
#
|
|
40
|
+
# @return [String, Hash]
|
|
41
|
+
def execute(ref:, create: false)
|
|
42
|
+
@logger.info("#{self.class.name}#execute ref=#{ref.inspect} create=#{create}")
|
|
43
|
+
|
|
44
|
+
return { error: "invalid ref: #{ref.inspect}" } unless valid_ref?(ref)
|
|
45
|
+
|
|
46
|
+
args = ["checkout"]
|
|
47
|
+
args << "-b" if create
|
|
48
|
+
args << ref
|
|
49
|
+
|
|
50
|
+
allowed = SharedTools.execute?(tool: self.class.to_s, stuff: args.join(" "))
|
|
51
|
+
unless allowed
|
|
52
|
+
@logger.warn("User declined to checkout #{ref}")
|
|
53
|
+
return { error: "User declined to checkout #{ref}" }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
out, err, status = run_git(*args, repo_root: @repo_root)
|
|
57
|
+
result = git_result(out, err, status)
|
|
58
|
+
return result if result.is_a?(Hash)
|
|
59
|
+
|
|
60
|
+
# git prints checkout feedback to stderr on success.
|
|
61
|
+
[out, err].map(&:strip).reject(&:empty?).join("\n")
|
|
62
|
+
rescue => e
|
|
63
|
+
@logger.error("#{self.class.name} failed: #{e.message}")
|
|
64
|
+
{ error: e.message }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
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. Commits staged changes in the repo at repo_root with a
|
|
9
|
+
# message. The message is passed as a single argv element (-m), so it
|
|
10
|
+
# is never parsed by a shell. Requires user authorization (see
|
|
11
|
+
# SharedTools.execute?). Does not push.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# tool = SharedTools::Tools::Git::CommitTool.new
|
|
15
|
+
# tool.execute(message: "Fix the thing")
|
|
16
|
+
class CommitTool < ::RubyLLM::Tool
|
|
17
|
+
include Helpers
|
|
18
|
+
|
|
19
|
+
def self.tool_name = 'git_commit'
|
|
20
|
+
|
|
21
|
+
description "Commit staged changes in the repository with a message. Set all to also " \
|
|
22
|
+
"stage modified/deleted tracked files first (git commit -a). Does not push."
|
|
23
|
+
|
|
24
|
+
parameters do
|
|
25
|
+
string :message, description: "Commit message."
|
|
26
|
+
boolean :all, description: "Stage modified/deleted tracked files before committing (git commit -a). Default false.", 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 message [String]
|
|
37
|
+
# @param all [Boolean]
|
|
38
|
+
#
|
|
39
|
+
# @return [String, Hash]
|
|
40
|
+
def execute(message:, all: false)
|
|
41
|
+
@logger.info("#{self.class.name}#execute all=#{all}")
|
|
42
|
+
|
|
43
|
+
msg = message.to_s
|
|
44
|
+
return { error: "commit message must not be empty" } if msg.strip.empty?
|
|
45
|
+
|
|
46
|
+
args = ["commit"]
|
|
47
|
+
args << "-a" if all
|
|
48
|
+
args += ["-m", msg]
|
|
49
|
+
|
|
50
|
+
allowed = SharedTools.execute?(tool: self.class.to_s, stuff: "git commit#{all ? ' -a' : ''} -m #{msg.inspect}")
|
|
51
|
+
unless allowed
|
|
52
|
+
@logger.warn("User declined to commit")
|
|
53
|
+
return { error: "User declined to commit" }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
out, err, status = run_git(*args, repo_root: @repo_root)
|
|
57
|
+
result = git_result(out, err, status)
|
|
58
|
+
if result.is_a?(Hash)
|
|
59
|
+
combined = "#{out}\n#{err}"
|
|
60
|
+
return { error: "nothing to commit (stage changes first)" } if combined.match?(/nothing to commit/i)
|
|
61
|
+
|
|
62
|
+
return result
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
out.strip
|
|
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,69 @@
|
|
|
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 unified diff of the repo at repo_root. External
|
|
9
|
+
# diff drivers and textconv filters are disabled so a malicious repo
|
|
10
|
+
# can't turn a diff into command execution.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# tool = SharedTools::Tools::Git::DiffTool.new
|
|
14
|
+
# tool.execute
|
|
15
|
+
# tool.execute(staged: true, path: "lib/foo.rb")
|
|
16
|
+
class DiffTool < ::RubyLLM::Tool
|
|
17
|
+
include Helpers
|
|
18
|
+
|
|
19
|
+
def self.tool_name = 'git_diff'
|
|
20
|
+
|
|
21
|
+
description "Show a unified git diff for the repository. By default shows unstaged " \
|
|
22
|
+
"changes; set staged to show what's staged. Optionally scope to a path or diff " \
|
|
23
|
+
"against a ref. Read-only."
|
|
24
|
+
|
|
25
|
+
parameters do
|
|
26
|
+
boolean :staged, description: "Show staged changes (git diff --cached) instead of unstaged. Default false.", required: false
|
|
27
|
+
string :path, description: "Limit the diff to this path, relative to the repo root. Optional.", required: false
|
|
28
|
+
string :ref, description: "Diff against this commit/branch/tag instead of the index. Optional.", required: false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param repo_root [String] optional, defaults to the current directory
|
|
32
|
+
# @param logger [Logger] optional logger
|
|
33
|
+
def initialize(repo_root: nil, logger: nil)
|
|
34
|
+
@repo_root = repo_root || Dir.pwd
|
|
35
|
+
@logger = logger || RubyLLM.logger
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param staged [Boolean]
|
|
39
|
+
# @param path [String, nil]
|
|
40
|
+
# @param ref [String, nil]
|
|
41
|
+
#
|
|
42
|
+
# @return [String, Hash]
|
|
43
|
+
def execute(staged: false, path: nil, ref: nil)
|
|
44
|
+
@logger.info("#{self.class.name}#execute staged=#{staged} path=#{path.inspect} ref=#{ref.inspect}")
|
|
45
|
+
|
|
46
|
+
return { error: "invalid ref: #{ref.inspect}" } if ref && !valid_ref?(ref)
|
|
47
|
+
|
|
48
|
+
rel = repo_relative(path, repo_root: @repo_root)
|
|
49
|
+
args = ["diff", "--no-ext-diff", "--no-textconv", "--stat", "--patch"]
|
|
50
|
+
args << "--cached" if staged
|
|
51
|
+
args << ref if ref
|
|
52
|
+
args += ["--", rel] if rel
|
|
53
|
+
|
|
54
|
+
out, err, status = run_git(*args, repo_root: @repo_root)
|
|
55
|
+
result = git_result(out, err, status)
|
|
56
|
+
return result if result.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
result.strip.empty? ? "no changes" : result
|
|
59
|
+
rescue SecurityError => e
|
|
60
|
+
@logger.error("#{self.class.name} path denied: #{e.message}")
|
|
61
|
+
{ error: e.message }
|
|
62
|
+
rescue => e
|
|
63
|
+
@logger.error("#{self.class.name} failed: #{e.message}")
|
|
64
|
+
{ error: e.message }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "helpers"
|
|
4
|
+
|
|
5
|
+
module SharedTools
|
|
6
|
+
module Tools
|
|
7
|
+
module Git
|
|
8
|
+
# Read-only. Searches the repo's tracked files with `git grep` and
|
|
9
|
+
# returns matching lines with file:line prefixes. Faster and cleaner
|
|
10
|
+
# than a filesystem grep in a git repo because it only looks at
|
|
11
|
+
# tracked content and skips binary files.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# tool = SharedTools::Tools::Git::GrepTool.new
|
|
15
|
+
# tool.execute(pattern: "def execute")
|
|
16
|
+
class GrepTool < ::RubyLLM::Tool
|
|
17
|
+
include Helpers
|
|
18
|
+
|
|
19
|
+
def self.tool_name = 'git_grep'
|
|
20
|
+
|
|
21
|
+
description "Search the tracked files of the repository for a pattern using git grep, " \
|
|
22
|
+
"returning file:line: matches. Optionally restrict to a path, ignore case, or treat " \
|
|
23
|
+
"the pattern as a fixed string instead of a regex."
|
|
24
|
+
|
|
25
|
+
parameters do
|
|
26
|
+
string :pattern, description: "The pattern to search for (a basic regex unless fixed is true)."
|
|
27
|
+
string :path, description: "Restrict the search to this file or directory, relative to the repo root. Optional.", required: false
|
|
28
|
+
boolean :ignore_case, description: "Case-insensitive search. Default false.", required: false
|
|
29
|
+
boolean :fixed, description: "Treat the pattern as a literal string rather than a regex. Default false.", required: false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param repo_root [String] optional, defaults to the current directory
|
|
33
|
+
# @param logger [Logger] optional logger
|
|
34
|
+
def initialize(repo_root: nil, logger: nil)
|
|
35
|
+
@repo_root = repo_root || Dir.pwd
|
|
36
|
+
@logger = logger || RubyLLM.logger
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param pattern [String]
|
|
40
|
+
# @param path [String, nil]
|
|
41
|
+
# @param ignore_case [Boolean]
|
|
42
|
+
# @param fixed [Boolean]
|
|
43
|
+
#
|
|
44
|
+
# @return [String, Hash]
|
|
45
|
+
def execute(pattern:, path: nil, ignore_case: false, fixed: false)
|
|
46
|
+
@logger.info("#{self.class.name}#execute pattern=#{pattern.inspect} path=#{path.inspect}")
|
|
47
|
+
|
|
48
|
+
pat = pattern.to_s
|
|
49
|
+
return { error: "pattern must not be empty" } if pat.empty?
|
|
50
|
+
|
|
51
|
+
rel = repo_relative(path, repo_root: @repo_root)
|
|
52
|
+
args = ["grep", "-n", "-I", "--no-color"]
|
|
53
|
+
args << "-i" if ignore_case
|
|
54
|
+
args << "-F" if fixed
|
|
55
|
+
args += ["-e", pat] # -e guards against a pattern that begins with "-"
|
|
56
|
+
args += ["--", rel] if rel
|
|
57
|
+
|
|
58
|
+
out, err, status = run_git(*args, repo_root: @repo_root)
|
|
59
|
+
interpret(out, err, status)
|
|
60
|
+
rescue SecurityError => e
|
|
61
|
+
@logger.error("#{self.class.name} path denied: #{e.message}")
|
|
62
|
+
{ error: e.message }
|
|
63
|
+
rescue => e
|
|
64
|
+
@logger.error("#{self.class.name} failed: #{e.message}")
|
|
65
|
+
{ error: e.message }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# git grep exits 1 (with no error output) when there are simply no
|
|
71
|
+
# matches — that's a normal result, not a tool failure.
|
|
72
|
+
def interpret(out, err, status)
|
|
73
|
+
case status.exitstatus
|
|
74
|
+
when 0 then out
|
|
75
|
+
when 1 then err.to_s.strip.empty? ? "no matches" : { error: err.strip }
|
|
76
|
+
else { error: err.to_s.strip.empty? ? out.to_s.strip : err.strip }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|