shared_tools 0.4.4 → 0.6.0

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