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.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/README.md +211 -7
  4. data/lib/shared_tools/apply_patch_tool.rb +4 -0
  5. data/lib/shared_tools/bash_tool.rb +4 -0
  6. data/lib/shared_tools/bundle_tool.rb +4 -0
  7. data/lib/shared_tools/command_guard.rb +35 -0
  8. data/lib/shared_tools/csv_read_tool.rb +4 -0
  9. data/lib/shared_tools/csv_write_tool.rb +4 -0
  10. data/lib/shared_tools/data_path.rb +52 -0
  11. data/lib/shared_tools/diff_tool.rb +4 -0
  12. data/lib/shared_tools/download_file_tool.rb +4 -0
  13. data/lib/shared_tools/gem_tool.rb +4 -0
  14. data/lib/shared_tools/git_tool.rb +5 -0
  15. data/lib/shared_tools/glob_tool.rb +4 -0
  16. data/lib/shared_tools/http_request_tool.rb +4 -0
  17. data/lib/shared_tools/json_query_tool.rb +4 -0
  18. data/lib/shared_tools/lint_tool.rb +4 -0
  19. data/lib/shared_tools/mcp/brave_search_client.rb +18 -16
  20. data/lib/shared_tools/mcp/chart_client.rb +18 -15
  21. data/lib/shared_tools/mcp/client_support.rb +103 -0
  22. data/lib/shared_tools/mcp/github_client.rb +23 -19
  23. data/lib/shared_tools/mcp/hugging_face_client.rb +22 -22
  24. data/lib/shared_tools/mcp/memory_client.rb +20 -15
  25. data/lib/shared_tools/mcp/notion_client.rb +21 -19
  26. data/lib/shared_tools/mcp/playwright_client.rb +19 -15
  27. data/lib/shared_tools/mcp/sequential_thinking_client.rb +17 -14
  28. data/lib/shared_tools/mcp/slack_client.rb +30 -25
  29. data/lib/shared_tools/mcp/tavily_client.rb +21 -21
  30. data/lib/shared_tools/mcp.rb +32 -41
  31. data/lib/shared_tools/multi_edit_tool.rb +4 -0
  32. data/lib/shared_tools/parse_ruby_tool.rb +4 -0
  33. data/lib/shared_tools/process_kill_tool.rb +4 -0
  34. data/lib/shared_tools/process_list_tool.rb +4 -0
  35. data/lib/shared_tools/process_output_tool.rb +4 -0
  36. data/lib/shared_tools/process_registry.rb +224 -0
  37. data/lib/shared_tools/process_runner.rb +69 -0
  38. data/lib/shared_tools/process_start_tool.rb +4 -0
  39. data/lib/shared_tools/python_tests_tool.rb +4 -0
  40. data/lib/shared_tools/replace_in_files_tool.rb +4 -0
  41. data/lib/shared_tools/ruby_outline.rb +207 -0
  42. data/lib/shared_tools/run_tests_tool.rb +4 -0
  43. data/lib/shared_tools/text_diff.rb +80 -0
  44. data/lib/shared_tools/todo_write_tool.rb +4 -0
  45. data/lib/shared_tools/toml_parser.rb +409 -0
  46. data/lib/shared_tools/toml_query_tool.rb +4 -0
  47. data/lib/shared_tools/tools/apply_patch_tool.rb +122 -0
  48. data/lib/shared_tools/tools/bash_tool.rb +102 -0
  49. data/lib/shared_tools/tools/browser/click_tool.rb +2 -2
  50. data/lib/shared_tools/tools/browser/inspect_tool.rb +2 -2
  51. data/lib/shared_tools/tools/browser/page_inspect_tool.rb +2 -2
  52. data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +2 -2
  53. data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +2 -2
  54. data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +2 -2
  55. data/lib/shared_tools/tools/browser/visit_tool.rb +2 -2
  56. data/lib/shared_tools/tools/browser_tool.rb +2 -2
  57. data/lib/shared_tools/tools/bundle_tool.rb +96 -0
  58. data/lib/shared_tools/tools/calculator_tool.rb +2 -2
  59. data/lib/shared_tools/tools/clipboard_tool.rb +2 -2
  60. data/lib/shared_tools/tools/composite_analysis_tool.rb +2 -2
  61. data/lib/shared_tools/tools/computer_tool.rb +2 -2
  62. data/lib/shared_tools/tools/cron_tool.rb +2 -2
  63. data/lib/shared_tools/tools/csv_read_tool.rb +82 -0
  64. data/lib/shared_tools/tools/csv_write_tool.rb +67 -0
  65. data/lib/shared_tools/tools/current_date_time_tool.rb +3 -3
  66. data/lib/shared_tools/tools/data_science_kit.rb +2 -2
  67. data/lib/shared_tools/tools/database_query_tool.rb +2 -2
  68. data/lib/shared_tools/tools/database_tool.rb +2 -2
  69. data/lib/shared_tools/tools/diff_tool.rb +48 -0
  70. data/lib/shared_tools/tools/disk/directory_create_tool.rb +2 -2
  71. data/lib/shared_tools/tools/disk/directory_delete_tool.rb +2 -2
  72. data/lib/shared_tools/tools/disk/directory_list_tool.rb +2 -2
  73. data/lib/shared_tools/tools/disk/directory_move_tool.rb +2 -2
  74. data/lib/shared_tools/tools/disk/file_create_tool.rb +2 -2
  75. data/lib/shared_tools/tools/disk/file_delete_tool.rb +2 -2
  76. data/lib/shared_tools/tools/disk/file_move_tool.rb +2 -2
  77. data/lib/shared_tools/tools/disk/file_read_tool.rb +2 -2
  78. data/lib/shared_tools/tools/disk/file_replace_tool.rb +2 -2
  79. data/lib/shared_tools/tools/disk/file_write_tool.rb +2 -2
  80. data/lib/shared_tools/tools/disk_tool.rb +2 -2
  81. data/lib/shared_tools/tools/dns_tool.rb +2 -2
  82. data/lib/shared_tools/tools/doc/docx_reader_tool.rb +2 -2
  83. data/lib/shared_tools/tools/doc/pdf_reader_tool.rb +2 -2
  84. data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +2 -2
  85. data/lib/shared_tools/tools/doc/text_reader_tool.rb +2 -2
  86. data/lib/shared_tools/tools/doc_tool.rb +2 -2
  87. data/lib/shared_tools/tools/docker/compose_run_tool.rb +2 -2
  88. data/lib/shared_tools/tools/download_file_tool.rb +87 -0
  89. data/lib/shared_tools/tools/enabler.rb +2 -2
  90. data/lib/shared_tools/tools/error_handling_tool.rb +2 -2
  91. data/lib/shared_tools/tools/eval/python_eval_tool.rb +2 -2
  92. data/lib/shared_tools/tools/eval/ruby_eval_tool.rb +2 -2
  93. data/lib/shared_tools/tools/eval/shell_eval_tool.rb +2 -2
  94. data/lib/shared_tools/tools/eval_tool.rb +2 -2
  95. data/lib/shared_tools/tools/gem_tool.rb +157 -0
  96. data/lib/shared_tools/tools/git/add_tool.rb +73 -0
  97. data/lib/shared_tools/tools/git/blame_tool.rb +77 -0
  98. data/lib/shared_tools/tools/git/branch_tool.rb +55 -0
  99. data/lib/shared_tools/tools/git/checkout_tool.rb +69 -0
  100. data/lib/shared_tools/tools/git/commit_tool.rb +73 -0
  101. data/lib/shared_tools/tools/git/diff_tool.rb +69 -0
  102. data/lib/shared_tools/tools/git/grep_tool.rb +82 -0
  103. data/lib/shared_tools/tools/git/helpers.rb +70 -0
  104. data/lib/shared_tools/tools/git/log_tool.rb +71 -0
  105. data/lib/shared_tools/tools/git/show_tool.rb +66 -0
  106. data/lib/shared_tools/tools/git/status_tool.rb +46 -0
  107. data/lib/shared_tools/tools/git.rb +18 -0
  108. data/lib/shared_tools/tools/git_tool.rb +148 -0
  109. data/lib/shared_tools/tools/glob_tool.rb +75 -0
  110. data/lib/shared_tools/tools/http_helpers.rb +119 -0
  111. data/lib/shared_tools/tools/http_request_tool.rb +91 -0
  112. data/lib/shared_tools/tools/json_query_tool.rb +81 -0
  113. data/lib/shared_tools/tools/lint_tool.rb +93 -0
  114. data/lib/shared_tools/tools/multi_edit_tool.rb +134 -0
  115. data/lib/shared_tools/tools/notification_tool.rb +2 -2
  116. data/lib/shared_tools/tools/parse_ruby_tool.rb +127 -0
  117. data/lib/shared_tools/tools/process_kill_tool.rb +55 -0
  118. data/lib/shared_tools/tools/process_list_tool.rb +41 -0
  119. data/lib/shared_tools/tools/process_output_tool.rb +69 -0
  120. data/lib/shared_tools/tools/process_start_tool.rb +0 -0
  121. data/lib/shared_tools/tools/python_tests_tool.rb +105 -0
  122. data/lib/shared_tools/tools/replace_in_files_tool.rb +166 -0
  123. data/lib/shared_tools/tools/run_tests_tool.rb +108 -0
  124. data/lib/shared_tools/tools/search_codebase_tool.rb +182 -51
  125. data/lib/shared_tools/tools/secure_tool_template.rb +2 -2
  126. data/lib/shared_tools/tools/system_info_tool.rb +2 -2
  127. data/lib/shared_tools/tools/todo_write_tool.rb +81 -0
  128. data/lib/shared_tools/tools/toml_query_tool.rb +81 -0
  129. data/lib/shared_tools/tools/toolchain_helpers.rb +64 -0
  130. data/lib/shared_tools/tools/tree_tool.rb +100 -0
  131. data/lib/shared_tools/tools/weather_tool.rb +2 -2
  132. data/lib/shared_tools/tools/web_fetch_tool.rb +85 -0
  133. data/lib/shared_tools/tools/workflow_manager_tool.rb +2 -2
  134. data/lib/shared_tools/tools/yaml_query_tool.rb +86 -0
  135. data/lib/shared_tools/tree_tool.rb +4 -0
  136. data/lib/shared_tools/url_guard.rb +106 -0
  137. data/lib/shared_tools/utilities.rb +2 -2
  138. data/lib/shared_tools/version.rb +1 -1
  139. data/lib/shared_tools/web_fetch_tool.rb +4 -0
  140. data/lib/shared_tools/yaml_query_tool.rb +4 -0
  141. data/lib/shared_tools.rb +1 -1
  142. metadata +532 -409
  143. data/lib/shared_tools/mcp/streamable_http_patch.rb +0 -42
  144. data/lib/shared_tools/tools/version.rb +0 -7
@@ -0,0 +1,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