ruby_llm-toolbox 0.1.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 (83) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +49 -0
  3. data/GUIDE.md +598 -0
  4. data/LICENSE +21 -0
  5. data/README.md +412 -0
  6. data/bin/verify_prism_parity +112 -0
  7. data/lib/ruby_llm/toolbox/base.rb +112 -0
  8. data/lib/ruby_llm/toolbox/configuration.rb +148 -0
  9. data/lib/ruby_llm/toolbox/data_path.rb +54 -0
  10. data/lib/ruby_llm/toolbox/process_registry.rb +226 -0
  11. data/lib/ruby_llm/toolbox/process_runner.rb +72 -0
  12. data/lib/ruby_llm/toolbox/ruby_outline.rb +213 -0
  13. data/lib/ruby_llm/toolbox/safe_math.rb +182 -0
  14. data/lib/ruby_llm/toolbox/safety/command_guard.rb +42 -0
  15. data/lib/ruby_llm/toolbox/safety/path_jail.rb +55 -0
  16. data/lib/ruby_llm/toolbox/safety/url_guard.rb +111 -0
  17. data/lib/ruby_llm/toolbox/sandbox/base.rb +151 -0
  18. data/lib/ruby_llm/toolbox/sandbox/bubblewrap.rb +70 -0
  19. data/lib/ruby_llm/toolbox/sandbox/docker.rb +69 -0
  20. data/lib/ruby_llm/toolbox/sandbox/sandbox_exec.rb +75 -0
  21. data/lib/ruby_llm/toolbox/search/brave.rb +64 -0
  22. data/lib/ruby_llm/toolbox/search/searxng.rb +64 -0
  23. data/lib/ruby_llm/toolbox/search/tavily.rb +70 -0
  24. data/lib/ruby_llm/toolbox/text_diff.rb +81 -0
  25. data/lib/ruby_llm/toolbox/toml.rb +409 -0
  26. data/lib/ruby_llm/toolbox/tools/apply_patch.rb +92 -0
  27. data/lib/ruby_llm/toolbox/tools/bash_tool.rb +101 -0
  28. data/lib/ruby_llm/toolbox/tools/bundle.rb +71 -0
  29. data/lib/ruby_llm/toolbox/tools/calculator.rb +42 -0
  30. data/lib/ruby_llm/toolbox/tools/create_directory.rb +35 -0
  31. data/lib/ruby_llm/toolbox/tools/csv_read.rb +69 -0
  32. data/lib/ruby_llm/toolbox/tools/csv_write.rb +51 -0
  33. data/lib/ruby_llm/toolbox/tools/date_time.rb +42 -0
  34. data/lib/ruby_llm/toolbox/tools/delete_file.rb +64 -0
  35. data/lib/ruby_llm/toolbox/tools/diff.rb +35 -0
  36. data/lib/ruby_llm/toolbox/tools/download_file.rb +55 -0
  37. data/lib/ruby_llm/toolbox/tools/edit_file.rb +82 -0
  38. data/lib/ruby_llm/toolbox/tools/gem_tool.rb +140 -0
  39. data/lib/ruby_llm/toolbox/tools/git_add.rb +46 -0
  40. data/lib/ruby_llm/toolbox/tools/git_blame.rb +58 -0
  41. data/lib/ruby_llm/toolbox/tools/git_branch.rb +35 -0
  42. data/lib/ruby_llm/toolbox/tools/git_checkout.rb +43 -0
  43. data/lib/ruby_llm/toolbox/tools/git_commit.rb +47 -0
  44. data/lib/ruby_llm/toolbox/tools/git_diff.rb +50 -0
  45. data/lib/ruby_llm/toolbox/tools/git_grep.rb +66 -0
  46. data/lib/ruby_llm/toolbox/tools/git_helpers.rb +68 -0
  47. data/lib/ruby_llm/toolbox/tools/git_log.rb +47 -0
  48. data/lib/ruby_llm/toolbox/tools/git_show.rb +48 -0
  49. data/lib/ruby_llm/toolbox/tools/git_status.rb +27 -0
  50. data/lib/ruby_llm/toolbox/tools/glob.rb +62 -0
  51. data/lib/ruby_llm/toolbox/tools/grep_files.rb +221 -0
  52. data/lib/ruby_llm/toolbox/tools/http_helpers.rb +130 -0
  53. data/lib/ruby_llm/toolbox/tools/http_request.rb +75 -0
  54. data/lib/ruby_llm/toolbox/tools/json_query.rb +69 -0
  55. data/lib/ruby_llm/toolbox/tools/lint.rb +67 -0
  56. data/lib/ruby_llm/toolbox/tools/list_directory.rb +87 -0
  57. data/lib/ruby_llm/toolbox/tools/move_file.rb +54 -0
  58. data/lib/ruby_llm/toolbox/tools/multi_edit.rb +107 -0
  59. data/lib/ruby_llm/toolbox/tools/parse_ruby.rb +111 -0
  60. data/lib/ruby_llm/toolbox/tools/process_kill.rb +41 -0
  61. data/lib/ruby_llm/toolbox/tools/process_list.rb +29 -0
  62. data/lib/ruby_llm/toolbox/tools/process_output.rb +55 -0
  63. data/lib/ruby_llm/toolbox/tools/process_start.rb +109 -0
  64. data/lib/ruby_llm/toolbox/tools/python_tests.rb +77 -0
  65. data/lib/ruby_llm/toolbox/tools/read_file.rb +75 -0
  66. data/lib/ruby_llm/toolbox/tools/replace_in_files.rb +139 -0
  67. data/lib/ruby_llm/toolbox/tools/run_python.rb +38 -0
  68. data/lib/ruby_llm/toolbox/tools/run_ruby.rb +37 -0
  69. data/lib/ruby_llm/toolbox/tools/run_rust.rb +42 -0
  70. data/lib/ruby_llm/toolbox/tools/run_tests.rb +81 -0
  71. data/lib/ruby_llm/toolbox/tools/sandbox_run.rb +40 -0
  72. data/lib/ruby_llm/toolbox/tools/todo_write.rb +57 -0
  73. data/lib/ruby_llm/toolbox/tools/toml_query.rb +70 -0
  74. data/lib/ruby_llm/toolbox/tools/toolchain_helpers.rb +62 -0
  75. data/lib/ruby_llm/toolbox/tools/tree.rb +87 -0
  76. data/lib/ruby_llm/toolbox/tools/web_fetch.rb +77 -0
  77. data/lib/ruby_llm/toolbox/tools/web_search.rb +81 -0
  78. data/lib/ruby_llm/toolbox/tools/write_file.rb +52 -0
  79. data/lib/ruby_llm/toolbox/tools/yaml_query.rb +73 -0
  80. data/lib/ruby_llm/toolbox/truncator.rb +68 -0
  81. data/lib/ruby_llm/toolbox/version.rb +7 -0
  82. data/lib/ruby_llm/toolbox.rb +161 -0
  83. metadata +194 -0
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm/toolbox/base"
4
+ require "ruby_llm/toolbox/safe_math"
5
+
6
+ module RubyLLM
7
+ module Toolbox
8
+ module Tools
9
+ # SAFE. Evaluates an arithmetic expression without eval, so it can't
10
+ # execute code. Supports + - * / % **, parentheses, unary minus, the
11
+ # functions sqrt/abs/sin/cos/tan/ln/log10/exp/floor/ceil/round, and the
12
+ # constants pi and e.
13
+ class Calculator < Base
14
+ description "Evaluate an arithmetic expression and return the result. Supports + - * / % **, " \
15
+ "parentheses, sqrt/abs/sin/cos/tan/ln/log10/exp/floor/ceil/round, and pi/e. " \
16
+ "Does not execute code."
17
+
18
+ param :expression, type: "string",
19
+ desc: "The arithmetic expression, e.g. '(1 + 2) * 3' or 'sqrt(2) * pi'.",
20
+ required: true
21
+
22
+ def execute(expression:)
23
+ return error("expression must not be empty", code: :empty) if expression.to_s.strip.empty?
24
+
25
+ value = SafeMath.evaluate(expression)
26
+ "#{expression.strip} = #{format_number(value)}"
27
+ rescue SafeMath::Error => e
28
+ error(e.message, code: :bad_expression)
29
+ end
30
+
31
+ private
32
+
33
+ def format_number(value)
34
+ return value.to_s unless value.is_a?(Float)
35
+ return value.to_i.to_s if value.finite? && value == value.to_i
36
+
37
+ value.to_s
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "ruby_llm/toolbox/base"
5
+ require "ruby_llm/toolbox/safety/path_jail"
6
+
7
+ module RubyLLM
8
+ module Toolbox
9
+ module Tools
10
+ # EXEC. Creates a directory (and any missing parents) within fs_root.
11
+ class CreateDirectory < Base
12
+ exec_tool!
13
+
14
+ description "Create a directory within fs_root, including any missing parent directories. " \
15
+ "Succeeds quietly if it already exists."
16
+
17
+ param :path, type: "string",
18
+ desc: "Directory path relative to fs_root.",
19
+ required: true
20
+
21
+ def execute(path:)
22
+ jail = Safety::PathJail.new(config.fs_root)
23
+ real = jail.resolve(path)
24
+ return error("a file already exists at #{path}", code: :is_a_file) if File.file?(real)
25
+
26
+ existed = File.directory?(real)
27
+ FileUtils.mkdir_p(real)
28
+ existed ? "Directory already exists: #{path}" : "Created directory #{path}"
29
+ rescue Safety::PathJail::Jailbreak => e
30
+ error(e.message, code: :path_denied)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "ruby_llm/toolbox/base"
5
+ require "ruby_llm/toolbox/safety/path_jail"
6
+
7
+ module RubyLLM
8
+ module Toolbox
9
+ module Tools
10
+ # SAFE. Reads a CSV file within fs_root and returns its rows in a compact
11
+ # readable form. Read-only.
12
+ class CsvRead < Base
13
+ description "Read a CSV file within fs_root and return its rows. By default the first row is " \
14
+ "treated as a header. Use limit to cap the number of data rows returned."
15
+
16
+ MAX_BYTES = 10 * 1024 * 1024
17
+ DEFAULT_LIMIT = 100
18
+
19
+ param :path, type: "string",
20
+ desc: "CSV file to read, relative to fs_root.",
21
+ required: true
22
+ param :headers, type: "boolean",
23
+ desc: "Treat the first row as column headers. Default true.",
24
+ required: false
25
+ param :limit, type: "integer",
26
+ desc: "Maximum number of data rows to return (default 100).",
27
+ required: false
28
+
29
+ def execute(path:, headers: true, limit: DEFAULT_LIMIT)
30
+ jail = Safety::PathJail.new(config.fs_root)
31
+ real = jail.resolve(path)
32
+ return error("not a file: #{path}", code: :not_a_file) unless File.file?(real)
33
+ return error("file too large (> #{MAX_BYTES} bytes)", code: :too_large) if File.size(real) > MAX_BYTES
34
+
35
+ rows = CSV.read(real)
36
+ return "empty CSV" if rows.empty?
37
+
38
+ max = limit.to_i
39
+ max = DEFAULT_LIMIT if max <= 0
40
+ truncate(render(rows, headers, max))
41
+ rescue Safety::PathJail::Jailbreak => e
42
+ error(e.message, code: :path_denied)
43
+ rescue CSV::MalformedCSVError => e
44
+ error("malformed CSV: #{e.message}", code: :bad_csv)
45
+ end
46
+
47
+ private
48
+
49
+ def render(rows, headers, max)
50
+ header = headers ? rows.first : nil
51
+ data = headers ? rows[1..] : rows
52
+ shown = data.first(max)
53
+
54
+ lines = []
55
+ lines << "columns: #{header.join(' | ')}" if header
56
+ lines << "#{data.size} row#{data.size == 1 ? '' : 's'}#{data.size > max ? " (showing #{max})" : ''}"
57
+ shown.each_with_index do |row, i|
58
+ lines << if header
59
+ "#{i + 1}. " + header.zip(row).map { |k, v| "#{k}=#{v}" }.join(", ")
60
+ else
61
+ "#{i + 1}. " + row.join(" | ")
62
+ end
63
+ end
64
+ lines.join("\n")
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "fileutils"
5
+ require "ruby_llm/toolbox/base"
6
+ require "ruby_llm/toolbox/safety/path_jail"
7
+
8
+ module RubyLLM
9
+ module Toolbox
10
+ module Tools
11
+ # EXEC. Writes rows to a CSV file within fs_root. Optional header row.
12
+ # Missing parent directories are created.
13
+ class CsvWrite < Base
14
+ exec_tool!
15
+
16
+ description "Write rows to a CSV file within fs_root. Provide rows as an array of arrays " \
17
+ "(each inner array is a row of cell values), with an optional headers array " \
18
+ "written as the first line. Overwrites an existing file."
19
+
20
+ param :path, type: "string",
21
+ desc: "Destination CSV path, relative to fs_root.",
22
+ required: true
23
+ param :rows, type: "array",
24
+ desc: "Array of rows; each row is an array of cell values.",
25
+ required: true
26
+ param :headers, type: "array",
27
+ desc: "Optional column header row, written first.",
28
+ required: false
29
+
30
+ def execute(path:, rows:, headers: nil)
31
+ return error("rows must be an array of arrays", code: :bad_rows) unless rows.is_a?(Array)
32
+
33
+ jail = Safety::PathJail.new(config.fs_root)
34
+ real = jail.resolve(path)
35
+ return error("path is a directory: #{path}", code: :is_a_directory) if File.directory?(real)
36
+
37
+ all = []
38
+ all << Array(headers) if headers && !Array(headers).empty?
39
+ rows.each { |row| all << Array(row) }
40
+
41
+ FileUtils.mkdir_p(File.dirname(real))
42
+ CSV.open(real, "w") { |csv| all.each { |row| csv << row } }
43
+
44
+ "Wrote #{rows.size} row#{rows.size == 1 ? '' : 's'} to #{path}"
45
+ rescue Safety::PathJail::Jailbreak => e
46
+ error(e.message, code: :path_denied)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "ruby_llm/toolbox/base"
5
+
6
+ module RubyLLM
7
+ module Toolbox
8
+ module Tools
9
+ # SAFE. Reports the current date/time (or converts a given unix timestamp),
10
+ # with optional strftime formatting.
11
+ class DateTime < Base
12
+ description "Get the current date and time, or convert a unix timestamp. Optionally format " \
13
+ "with a strftime pattern. Returns ISO 8601, unix time, weekday, and timezone."
14
+
15
+ param :format, type: "string",
16
+ desc: "Optional strftime pattern, e.g. '%Y-%m-%d %H:%M'. If given, returns just that.",
17
+ required: false
18
+ param :unix, type: "integer",
19
+ desc: "Optional unix timestamp to convert instead of using the current time.",
20
+ required: false
21
+
22
+ def execute(format: nil, unix: nil)
23
+ time = unix.nil? ? Time.now : Time.at(unix.to_i).utc
24
+
25
+ if format && !format.to_s.strip.empty?
26
+ return time.strftime(format.to_s)
27
+ end
28
+
29
+ [
30
+ "iso8601: #{time.iso8601}",
31
+ "unix: #{time.to_i}",
32
+ "date: #{time.strftime('%A, %B %-d, %Y')}",
33
+ "time: #{time.strftime('%H:%M:%S')}",
34
+ "timezone: #{time.strftime('%Z (%z)')}"
35
+ ].join("\n")
36
+ rescue StandardError => e
37
+ error("could not format time: #{e.message}", code: :bad_format)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "ruby_llm/toolbox/base"
5
+ require "ruby_llm/toolbox/safety/path_jail"
6
+
7
+ module RubyLLM
8
+ module Toolbox
9
+ module Tools
10
+ # EXEC. Deletes a file or directory within fs_root. Deleting a non-empty
11
+ # directory requires recursive: true, so a single mistaken call can't wipe
12
+ # a tree by accident. Confinement to the jail bounds the blast radius to
13
+ # fs_root regardless.
14
+ class DeleteFile < Base
15
+ exec_tool!
16
+
17
+ description "Delete a file or directory within fs_root. A non-empty directory is only " \
18
+ "removed when recursive is true. Refuses to delete fs_root itself."
19
+
20
+ param :path, type: "string",
21
+ desc: "Path to delete, relative to fs_root.",
22
+ required: true
23
+ param :recursive, type: "boolean",
24
+ desc: "Allow deleting a non-empty directory and its contents. Default false.",
25
+ required: false
26
+
27
+ def execute(path:, recursive: false)
28
+ jail = Safety::PathJail.new(config.fs_root)
29
+ real = jail.resolve(path)
30
+
31
+ return error("refusing to delete fs_root itself", code: :refused) if real == jail.root
32
+ return error("path does not exist: #{path}", code: :not_found) unless File.exist?(real)
33
+
34
+ if File.directory?(real)
35
+ delete_directory(real, path, recursive)
36
+ else
37
+ File.delete(real)
38
+ "Deleted file #{path}"
39
+ end
40
+ rescue Safety::PathJail::Jailbreak => e
41
+ error(e.message, code: :path_denied)
42
+ end
43
+
44
+ private
45
+
46
+ def delete_directory(real, path, recursive)
47
+ empty = Dir.empty?(real)
48
+ if empty
49
+ Dir.rmdir(real)
50
+ "Deleted empty directory #{path}"
51
+ elsif recursive
52
+ count = Dir.glob(File.join(real, "**", "*"), File::FNM_DOTMATCH)
53
+ .reject { |p| %w[. ..].include?(File.basename(p)) }.size
54
+ FileUtils.rm_rf(real)
55
+ "Deleted directory #{path} and #{count} item#{count == 1 ? '' : 's'} inside it"
56
+ else
57
+ error("directory is not empty: #{path} (set recursive to delete its contents)",
58
+ code: :not_empty)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm/toolbox/base"
4
+ require "ruby_llm/toolbox/text_diff"
5
+
6
+ module RubyLLM
7
+ module Toolbox
8
+ module Tools
9
+ # SAFE. Compares two blocks of text and returns a readable line diff.
10
+ class Diff < Base
11
+ description "Compare two blocks of text and return a readable line-by-line diff " \
12
+ "(removed lines prefixed -, added lines prefixed +). In-process."
13
+
14
+ param :old, type: "string",
15
+ desc: "The original text.",
16
+ required: true
17
+ param :new, type: "string",
18
+ desc: "The changed text.",
19
+ required: true
20
+ param :old_label, type: "string",
21
+ desc: "Label for the original side. Default 'old'.",
22
+ required: false
23
+ param :new_label, type: "string",
24
+ desc: "Label for the changed side. Default 'new'.",
25
+ required: false
26
+
27
+ def execute(old:, new:, old_label: "old", new_label: "new")
28
+ diff = TextDiff.unified(old.to_s, new.to_s,
29
+ old_label: old_label.to_s, new_label: new_label.to_s)
30
+ truncate(diff)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "ruby_llm/toolbox/base"
5
+ require "ruby_llm/toolbox/tools/http_helpers"
6
+ require "ruby_llm/toolbox/safety/path_jail"
7
+
8
+ module RubyLLM
9
+ module Toolbox
10
+ module Tools
11
+ # EXEC. Downloads a URL to a file within fs_root. The request goes through
12
+ # UrlGuard (so it can't be pointed at internal/loopback/metadata
13
+ # addresses), follows redirects safely, and is capped at
14
+ # config.max_fetch_bytes. The destination path is jailed to fs_root.
15
+ class DownloadFile < Base
16
+ include HttpHelpers
17
+ exec_tool!
18
+
19
+ description "Download an http/https URL to a file within fs_root. SSRF-protected and " \
20
+ "size-capped. Use for fetching an asset or release to disk (web_fetch returns text " \
21
+ "instead). Overwrites an existing file."
22
+
23
+ param :url, type: "string",
24
+ desc: "The http/https URL to download.",
25
+ required: true
26
+ param :path, type: "string",
27
+ desc: "Destination path, relative to fs_root.",
28
+ required: true
29
+
30
+ def execute(url:, path:)
31
+ jail = Safety::PathJail.new(config.fs_root)
32
+ real = jail.resolve(path)
33
+ return error("path is a directory: #{path}", code: :is_a_directory) if File.directory?(real)
34
+
35
+ response = guarded_get(url)
36
+ if response.status >= 400
37
+ return error("server returned HTTP #{response.status} for #{response.final_url}", code: :http_error)
38
+ end
39
+
40
+ body = response.body.to_s
41
+ FileUtils.mkdir_p(File.dirname(real))
42
+ File.binwrite(real, body)
43
+
44
+ "Downloaded #{body.bytesize} bytes from #{response.final_url} to #{path}"
45
+ rescue Safety::PathJail::Jailbreak => e
46
+ error(e.message, code: :path_denied)
47
+ rescue Safety::UrlGuard::Blocked => e
48
+ error(e.message, code: :url_blocked)
49
+ rescue HttpHelpers::FetchError => e
50
+ error("download failed: #{e.message}", code: :fetch_failed)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm/toolbox/base"
4
+ require "ruby_llm/toolbox/safety/path_jail"
5
+
6
+ module RubyLLM
7
+ module Toolbox
8
+ module Tools
9
+ # EXEC. The core editing primitive: replace an exact substring in a file.
10
+ #
11
+ # By default old_string must match EXACTLY ONCE — if it's missing the edit
12
+ # fails, and if it's ambiguous (appears more than once) the edit also fails
13
+ # rather than guessing. Set replace_all to change every occurrence. This is
14
+ # the same contract coding agents rely on from a str_replace tool: it makes
15
+ # edits deterministic and refuses to silently do the wrong thing.
16
+ class EditFile < Base
17
+ exec_tool!
18
+
19
+ description "Replace an exact substring in a text file within fs_root. old_string must " \
20
+ "match exactly once (include enough surrounding context to be unique), or set " \
21
+ "replace_all to change every occurrence. Fails clearly if old_string is missing " \
22
+ "or ambiguous, so edits never land in the wrong place."
23
+
24
+ param :path, type: "string",
25
+ desc: "File path relative to fs_root.",
26
+ required: true
27
+ param :old_string, type: "string",
28
+ desc: "Exact text to replace. Include surrounding lines so it's unique.",
29
+ required: true
30
+ param :new_string, type: "string",
31
+ desc: "Replacement text.",
32
+ required: true
33
+ param :replace_all, type: "boolean",
34
+ desc: "Replace every occurrence instead of requiring a unique match. Default false.",
35
+ required: false
36
+
37
+ MAX_BYTES = 10 * 1024 * 1024
38
+
39
+ def execute(path:, old_string:, new_string:, replace_all: false)
40
+ old_s = old_string.to_s
41
+ new_s = new_string.to_s
42
+ return error("old_string must not be empty", code: :empty_match) if old_s.empty?
43
+ return error("old_string and new_string are identical; nothing to do", code: :no_change) if old_s == new_s
44
+
45
+ jail = Safety::PathJail.new(config.fs_root)
46
+ real = jail.resolve(path)
47
+ return error("not a file: #{path}", code: :not_a_file) unless File.file?(real)
48
+ return error("file too large (> #{MAX_BYTES} bytes)", code: :too_large) if File.size(real) > MAX_BYTES
49
+
50
+ original = File.read(real).scrub
51
+ count = original.scan(old_string_regexp(old_s)).size
52
+ return error("old_string not found in #{path}", code: :not_found) if count.zero?
53
+ if count > 1 && !replace_all
54
+ return error("old_string is ambiguous: #{count} matches in #{path}. " \
55
+ "Add surrounding context to make it unique, or set replace_all.",
56
+ code: :ambiguous)
57
+ end
58
+
59
+ updated = if replace_all
60
+ original.gsub(old_s) { new_s }
61
+ else
62
+ original.sub(old_s) { new_s }
63
+ end
64
+ File.write(real, updated)
65
+
66
+ line = original[0...original.index(old_s)].count("\n") + 1
67
+ "Edited #{path} (#{count} replacement#{count == 1 ? '' : 's'}, " \
68
+ "#{original.bytesize} -> #{updated.bytesize} bytes, first change at line #{line})"
69
+ rescue Safety::PathJail::Jailbreak => e
70
+ error(e.message, code: :path_denied)
71
+ end
72
+
73
+ private
74
+
75
+ # Count occurrences by literal text, not as a pattern.
76
+ def old_string_regexp(old_s)
77
+ Regexp.new(Regexp.escape(old_s))
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require "ruby_llm/toolbox/base"
7
+
8
+ module RubyLLM
9
+ module Toolbox
10
+ module Tools
11
+ # SAFE. Read-only RubyGems.org metadata lookup. The host is fixed and all
12
+ # user input is URL-encoded into the path/query (and gem names are
13
+ # validated), so there is no arbitrary-URL / SSRF surface here.
14
+ #
15
+ # Tool name: "gem".
16
+ class GemTool < Base
17
+ class NotFound < StandardError; end
18
+ class HttpError < StandardError; end
19
+
20
+ description "Look up RubyGems.org metadata for a gem (read-only). Actions: info (summary), " \
21
+ "version (latest version), dependencies (runtime deps), search (find gems by " \
22
+ "query). For 'search', pass the query as name. Returns concise, token-budgeted text."
23
+
24
+ ACTIONS = %w[info version dependencies search].freeze
25
+ NAME_RE = /\A[A-Za-z0-9_.-]+\z/
26
+ HOST = "https://rubygems.org"
27
+
28
+ param :name, type: "string",
29
+ desc: "Gem name, or — for action 'search' — the search query.",
30
+ required: true
31
+ param :action, type: "string",
32
+ desc: "One of: info, version, dependencies, search. Default info.",
33
+ required: false
34
+
35
+ def execute(name:, action: "info")
36
+ action = normalize_action(action)
37
+ return error("unknown action: #{action} (use #{ACTIONS.join(', ')})", code: :bad_action) unless ACTIONS.include?(action)
38
+ return search(name) if action == "search"
39
+
40
+ gem = name.to_s.strip
41
+ return error("invalid gem name: #{gem.inspect}", code: :bad_name) unless gem.match?(NAME_RE)
42
+
43
+ case action
44
+ when "version" then version(gem)
45
+ when "dependencies" then dependencies(gem)
46
+ else info(gem)
47
+ end
48
+ rescue NotFound
49
+ error("gem not found: #{name}", code: :not_found)
50
+ rescue HttpError => e
51
+ error("rubygems.org request failed: #{e.message}", code: :http_error)
52
+ end
53
+
54
+ private
55
+
56
+ def normalize_action(action)
57
+ a = action.to_s.strip.downcase
58
+ a.empty? ? "info" : a
59
+ end
60
+
61
+ def info(gem)
62
+ data = get_json("#{HOST}/api/v1/gems/#{enc(gem)}.json")
63
+ lines = ["#{data['name']} #{data['version']}"]
64
+ lines << data["info"].to_s.strip unless data["info"].to_s.strip.empty?
65
+ lines << "homepage: #{data['homepage_uri']}" if data["homepage_uri"]
66
+ lines << "licenses: #{Array(data['licenses']).join(', ')}" if data["licenses"]
67
+ lines << "downloads: #{data['downloads']}" if data["downloads"]
68
+ runtime = data.dig("dependencies", "runtime") || []
69
+ unless runtime.empty?
70
+ lines << "runtime deps: #{runtime.map { |d| "#{d['name']} #{d['requirements']}" }.join('; ')}"
71
+ end
72
+ truncate(lines.join("\n"))
73
+ end
74
+
75
+ def version(gem)
76
+ data = get_json("#{HOST}/api/v1/versions/#{enc(gem)}/latest.json")
77
+ v = data["version"]
78
+ return error("gem not found: #{gem}", code: :not_found) if v.nil? || v == "unknown"
79
+
80
+ "#{gem} #{v}"
81
+ end
82
+
83
+ def dependencies(gem)
84
+ data = get_json("#{HOST}/api/v1/gems/#{enc(gem)}.json")
85
+ runtime = data.dig("dependencies", "runtime") || []
86
+ dev = data.dig("dependencies", "development") || []
87
+ return "#{gem} #{data['version']} has no runtime dependencies" if runtime.empty?
88
+
89
+ body = +"#{gem} #{data['version']} runtime dependencies:\n"
90
+ runtime.each { |d| body << " #{d['name']} #{d['requirements']}\n" }
91
+ body << "(+#{dev.size} development deps)" unless dev.empty?
92
+ truncate(body)
93
+ end
94
+
95
+ def search(query)
96
+ q = query.to_s.strip
97
+ return error("empty search query", code: :bad_name) if q.empty?
98
+
99
+ results = get_json("#{HOST}/api/v1/search.json?query=#{enc(q)}")
100
+ return "no gems found for #{q.inspect}" unless results.is_a?(Array) && results.any?
101
+
102
+ body = +"#{results.size} result#{results.size == 1 ? '' : 's'} for #{q.inspect}:\n"
103
+ results.first(20).each do |g|
104
+ summary = g["info"].to_s.split("\n").first
105
+ body << " #{g['name']} #{g['version']} — #{summary}\n"
106
+ end
107
+ truncate(body)
108
+ end
109
+
110
+ def enc(str)
111
+ URI.encode_www_form_component(str)
112
+ end
113
+
114
+ # Seam for tests. Performs a GET and parses JSON, mapping transport and
115
+ # not-found conditions onto the tool's error classes.
116
+ def get_json(url)
117
+ uri = URI.parse(url)
118
+ req = Net::HTTP::Get.new(uri)
119
+ req["User-Agent"] = config.user_agent
120
+ req["Accept"] = "application/json"
121
+
122
+ res = Net::HTTP.start(uri.host, uri.port,
123
+ use_ssl: uri.scheme == "https",
124
+ open_timeout: config.http_timeout,
125
+ read_timeout: config.http_timeout) { |http| http.request(req) }
126
+
127
+ case res
128
+ when Net::HTTPSuccess then JSON.parse(res.body)
129
+ when Net::HTTPNotFound then raise NotFound
130
+ else raise HttpError, "HTTP #{res.code}"
131
+ end
132
+ rescue JSON::ParserError => e
133
+ raise HttpError, "invalid JSON (#{e.message})"
134
+ rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
135
+ raise HttpError, e.message
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm/toolbox/base"
4
+ require "ruby_llm/toolbox/tools/git_helpers"
5
+
6
+ module RubyLLM
7
+ module Toolbox
8
+ module Tools
9
+ # EXEC. Stages changes in the repo at fs_root.
10
+ class GitAdd < Base
11
+ include GitHelpers
12
+ exec_tool!
13
+
14
+ description "Stage changes in the repository at fs_root. Pass specific paths, or set all to " \
15
+ "stage every change (git add -A)."
16
+
17
+ param :paths, type: "array",
18
+ desc: "Paths to stage, relative to fs_root. Each is confined to the repo.",
19
+ required: false
20
+ param :all, type: "boolean",
21
+ desc: "Stage all changes (git add -A) instead of specific paths. Default false.",
22
+ required: false
23
+
24
+ def execute(paths: nil, all: false)
25
+ rels = Array(paths).filter_map { |p| repo_relative(p) }
26
+
27
+ if all
28
+ args = ["add", "-A"]
29
+ elsif rels.empty?
30
+ return error("provide paths or set all: true", code: :nothing_to_add)
31
+ else
32
+ args = ["add", "--", *rels]
33
+ end
34
+
35
+ out, err, status = run_git(*args)
36
+ result = git_result(out, err, status)
37
+ return result if result.is_a?(Hash)
38
+
39
+ all ? "Staged all changes" : "Staged: #{rels.join(', ')}"
40
+ rescue Safety::PathJail::Jailbreak => e
41
+ error(e.message, code: :path_denied)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end