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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +49 -0
- data/GUIDE.md +598 -0
- data/LICENSE +21 -0
- data/README.md +412 -0
- data/bin/verify_prism_parity +112 -0
- data/lib/ruby_llm/toolbox/base.rb +112 -0
- data/lib/ruby_llm/toolbox/configuration.rb +148 -0
- data/lib/ruby_llm/toolbox/data_path.rb +54 -0
- data/lib/ruby_llm/toolbox/process_registry.rb +226 -0
- data/lib/ruby_llm/toolbox/process_runner.rb +72 -0
- data/lib/ruby_llm/toolbox/ruby_outline.rb +213 -0
- data/lib/ruby_llm/toolbox/safe_math.rb +182 -0
- data/lib/ruby_llm/toolbox/safety/command_guard.rb +42 -0
- data/lib/ruby_llm/toolbox/safety/path_jail.rb +55 -0
- data/lib/ruby_llm/toolbox/safety/url_guard.rb +111 -0
- data/lib/ruby_llm/toolbox/sandbox/base.rb +151 -0
- data/lib/ruby_llm/toolbox/sandbox/bubblewrap.rb +70 -0
- data/lib/ruby_llm/toolbox/sandbox/docker.rb +69 -0
- data/lib/ruby_llm/toolbox/sandbox/sandbox_exec.rb +75 -0
- data/lib/ruby_llm/toolbox/search/brave.rb +64 -0
- data/lib/ruby_llm/toolbox/search/searxng.rb +64 -0
- data/lib/ruby_llm/toolbox/search/tavily.rb +70 -0
- data/lib/ruby_llm/toolbox/text_diff.rb +81 -0
- data/lib/ruby_llm/toolbox/toml.rb +409 -0
- data/lib/ruby_llm/toolbox/tools/apply_patch.rb +92 -0
- data/lib/ruby_llm/toolbox/tools/bash_tool.rb +101 -0
- data/lib/ruby_llm/toolbox/tools/bundle.rb +71 -0
- data/lib/ruby_llm/toolbox/tools/calculator.rb +42 -0
- data/lib/ruby_llm/toolbox/tools/create_directory.rb +35 -0
- data/lib/ruby_llm/toolbox/tools/csv_read.rb +69 -0
- data/lib/ruby_llm/toolbox/tools/csv_write.rb +51 -0
- data/lib/ruby_llm/toolbox/tools/date_time.rb +42 -0
- data/lib/ruby_llm/toolbox/tools/delete_file.rb +64 -0
- data/lib/ruby_llm/toolbox/tools/diff.rb +35 -0
- data/lib/ruby_llm/toolbox/tools/download_file.rb +55 -0
- data/lib/ruby_llm/toolbox/tools/edit_file.rb +82 -0
- data/lib/ruby_llm/toolbox/tools/gem_tool.rb +140 -0
- data/lib/ruby_llm/toolbox/tools/git_add.rb +46 -0
- data/lib/ruby_llm/toolbox/tools/git_blame.rb +58 -0
- data/lib/ruby_llm/toolbox/tools/git_branch.rb +35 -0
- data/lib/ruby_llm/toolbox/tools/git_checkout.rb +43 -0
- data/lib/ruby_llm/toolbox/tools/git_commit.rb +47 -0
- data/lib/ruby_llm/toolbox/tools/git_diff.rb +50 -0
- data/lib/ruby_llm/toolbox/tools/git_grep.rb +66 -0
- data/lib/ruby_llm/toolbox/tools/git_helpers.rb +68 -0
- data/lib/ruby_llm/toolbox/tools/git_log.rb +47 -0
- data/lib/ruby_llm/toolbox/tools/git_show.rb +48 -0
- data/lib/ruby_llm/toolbox/tools/git_status.rb +27 -0
- data/lib/ruby_llm/toolbox/tools/glob.rb +62 -0
- data/lib/ruby_llm/toolbox/tools/grep_files.rb +221 -0
- data/lib/ruby_llm/toolbox/tools/http_helpers.rb +130 -0
- data/lib/ruby_llm/toolbox/tools/http_request.rb +75 -0
- data/lib/ruby_llm/toolbox/tools/json_query.rb +69 -0
- data/lib/ruby_llm/toolbox/tools/lint.rb +67 -0
- data/lib/ruby_llm/toolbox/tools/list_directory.rb +87 -0
- data/lib/ruby_llm/toolbox/tools/move_file.rb +54 -0
- data/lib/ruby_llm/toolbox/tools/multi_edit.rb +107 -0
- data/lib/ruby_llm/toolbox/tools/parse_ruby.rb +111 -0
- data/lib/ruby_llm/toolbox/tools/process_kill.rb +41 -0
- data/lib/ruby_llm/toolbox/tools/process_list.rb +29 -0
- data/lib/ruby_llm/toolbox/tools/process_output.rb +55 -0
- data/lib/ruby_llm/toolbox/tools/process_start.rb +109 -0
- data/lib/ruby_llm/toolbox/tools/python_tests.rb +77 -0
- data/lib/ruby_llm/toolbox/tools/read_file.rb +75 -0
- data/lib/ruby_llm/toolbox/tools/replace_in_files.rb +139 -0
- data/lib/ruby_llm/toolbox/tools/run_python.rb +38 -0
- data/lib/ruby_llm/toolbox/tools/run_ruby.rb +37 -0
- data/lib/ruby_llm/toolbox/tools/run_rust.rb +42 -0
- data/lib/ruby_llm/toolbox/tools/run_tests.rb +81 -0
- data/lib/ruby_llm/toolbox/tools/sandbox_run.rb +40 -0
- data/lib/ruby_llm/toolbox/tools/todo_write.rb +57 -0
- data/lib/ruby_llm/toolbox/tools/toml_query.rb +70 -0
- data/lib/ruby_llm/toolbox/tools/toolchain_helpers.rb +62 -0
- data/lib/ruby_llm/toolbox/tools/tree.rb +87 -0
- data/lib/ruby_llm/toolbox/tools/web_fetch.rb +77 -0
- data/lib/ruby_llm/toolbox/tools/web_search.rb +81 -0
- data/lib/ruby_llm/toolbox/tools/write_file.rb +52 -0
- data/lib/ruby_llm/toolbox/tools/yaml_query.rb +73 -0
- data/lib/ruby_llm/toolbox/truncator.rb +68 -0
- data/lib/ruby_llm/toolbox/version.rb +7 -0
- data/lib/ruby_llm/toolbox.rb +161 -0
- 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
|