shared_tools 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -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 +12 -2
  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 +12 -2
  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 +27 -6
  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 +222 -0
  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 +12 -2
  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 -394
  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 "Deletes a file."
15
15
 
16
- params do
16
+ parameters do
17
17
  string :path, description: "a path to the file (e.g. `./README.md`)"
18
18
  end
19
19
 
@@ -16,7 +16,7 @@ module SharedTools
16
16
 
17
17
  description "Moves a file."
18
18
 
19
- params do
19
+ parameters do
20
20
  string :path, description: "a path (e.g. `./old.rb`)"
21
21
  string :destination, description: "a path (e.g. `./new.rb`)"
22
22
  end
@@ -13,7 +13,7 @@ module SharedTools
13
13
 
14
14
  description "Reads the contents of a file."
15
15
 
16
- params do
16
+ parameters do
17
17
  string :path, description: "a path (e.g. `./main.rb`)"
18
18
  end
19
19
 
@@ -17,7 +17,7 @@ module SharedTools
17
17
 
18
18
  description "Replaces a specific string in a file (old_text => new_text)."
19
19
 
20
- params do
20
+ parameters do
21
21
  string :old_text, description: "the old text (e.g. `puts 'ABC'`)"
22
22
  string :new_text, description: "the new text (e.g. `puts 'DEF'`)"
23
23
  string :path, description: "a path (e.g. `./main.rb`)"
@@ -13,7 +13,7 @@ module SharedTools
13
13
 
14
14
  description "Writes the contents of a file."
15
15
 
16
- 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
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "open3"
3
4
 
4
5
  module SharedTools
5
6
  module Tools
@@ -7,13 +8,18 @@ module SharedTools
7
8
  # @example
8
9
  # tool = SharedTools::Tools::Eval::ShellEvalTool.new
9
10
  # tool.execute(command: "ls -la")
11
+ # tool.execute(command: "ls -la", workdir: "/tmp")
10
12
  class ShellEvalTool < ::RubyLLM::Tool
13
+ STDOUT_MAX = 5000
14
+ STDERR_MAX = 2000
15
+
11
16
  def self.name = 'eval_shell'
12
17
 
13
18
  description "Execute a shell command safely and return the result."
14
19
 
15
- params do
20
+ parameters do
16
21
  string :command, description: "The shell command to execute"
22
+ string :workdir, description: "Working directory for the command (default: current directory)", required: false
17
23
  end
18
24
 
19
25
  # @param logger [Logger] optional logger
@@ -22,9 +28,10 @@ module SharedTools
22
28
  end
23
29
 
24
30
  # @param command [String] shell command to execute
31
+ # @param workdir [String, nil] working directory for the command
25
32
  #
26
33
  # @return [Hash] execution result
27
- def execute(command:)
34
+ def execute(command:, workdir: nil)
28
35
  @logger.info("Requesting permission to execute command: '#{command}'")
29
36
 
30
37
  if command.strip.empty?
@@ -33,7 +40,12 @@ module SharedTools
33
40
  return { error: error_msg }
34
41
  end
35
42
 
36
- # Show user the command and ask for confirmation
43
+ if workdir
44
+ unless File.directory?(workdir)
45
+ return { error: "Working directory not found: #{workdir}" }
46
+ end
47
+ end
48
+
37
49
  allowed = SharedTools.execute?(tool: self.class.to_s, stuff: command)
38
50
 
39
51
  unless allowed
@@ -43,9 +55,11 @@ module SharedTools
43
55
 
44
56
  @logger.info("Executing command: '#{command}'")
45
57
 
46
- # Use Open3 for safer command execution with proper error handling
47
- require "open3"
48
- stdout, stderr, status = Open3.capture3(command)
58
+ opts = workdir ? { chdir: workdir } : {}
59
+ stdout, stderr, status = Open3.capture3(command, **opts)
60
+
61
+ stdout = truncate(stdout, STDOUT_MAX)
62
+ stderr = truncate(stderr, STDERR_MAX)
49
63
 
50
64
  if status.success?
51
65
  @logger.debug("Command execution completed successfully with #{stdout.bytesize} bytes of output")
@@ -58,6 +72,13 @@ module SharedTools
58
72
  @logger.error("Command execution failed: #{e.message}")
59
73
  { error: e.message }
60
74
  end
75
+
76
+ private
77
+
78
+ def truncate(str, max)
79
+ return str if str.length <= max
80
+ str.slice(0, max) + "\n[truncated]"
81
+ end
61
82
  end
62
83
  end
63
84
  end
@@ -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