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,58 @@
|
|
|
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
|
+
# SAFE. Shows line-by-line authorship (commit, author, date) for a file in
|
|
10
|
+
# the repo at fs_root. Read-only.
|
|
11
|
+
class GitBlame < Base
|
|
12
|
+
include GitHelpers
|
|
13
|
+
|
|
14
|
+
description "Show git blame for a file in the repo at fs_root: which commit, author, and date " \
|
|
15
|
+
"last touched each line. Optionally limit to a line range. Read-only."
|
|
16
|
+
|
|
17
|
+
param :path, type: "string",
|
|
18
|
+
desc: "File to blame, relative to fs_root.",
|
|
19
|
+
required: true
|
|
20
|
+
param :start_line, type: "integer",
|
|
21
|
+
desc: "First line of the range to blame. Optional.",
|
|
22
|
+
required: false
|
|
23
|
+
param :end_line, type: "integer",
|
|
24
|
+
desc: "Last line of the range to blame. Optional.",
|
|
25
|
+
required: false
|
|
26
|
+
|
|
27
|
+
def execute(path:, start_line: nil, end_line: nil)
|
|
28
|
+
rel = repo_relative(path)
|
|
29
|
+
return error("path must be provided", code: :bad_path) if rel.nil?
|
|
30
|
+
|
|
31
|
+
args = ["blame", "--date=short"]
|
|
32
|
+
if (range = line_range(start_line, end_line))
|
|
33
|
+
args += ["-L", range]
|
|
34
|
+
end
|
|
35
|
+
args += ["--", rel]
|
|
36
|
+
|
|
37
|
+
out, err, status = run_git(*args)
|
|
38
|
+
result = git_result(out, err, status)
|
|
39
|
+
return result if result.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
truncate(result.strip.empty? ? "no blame output (empty file?)" : result)
|
|
42
|
+
rescue Safety::PathJail::Jailbreak => e
|
|
43
|
+
error(e.message, code: :path_denied)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def line_range(start_line, end_line)
|
|
49
|
+
return nil if start_line.nil? && end_line.nil?
|
|
50
|
+
|
|
51
|
+
first = [start_line.to_i, 1].max
|
|
52
|
+
last = end_line.nil? ? "" : [end_line.to_i, first].max
|
|
53
|
+
"#{first},#{last}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
# SAFE. Lists the branches of the repo at fs_root, with the current branch
|
|
10
|
+
# marked and each branch's latest commit. Read-only; does not create,
|
|
11
|
+
# switch, or delete branches (git_checkout handles switching/creating).
|
|
12
|
+
class GitBranch < Base
|
|
13
|
+
include GitHelpers
|
|
14
|
+
|
|
15
|
+
description "List the branches of the repo at fs_root, with the current branch marked (*) and " \
|
|
16
|
+
"each branch's tip commit. Set all true to include remote-tracking branches. Read-only."
|
|
17
|
+
|
|
18
|
+
param :all, type: "boolean",
|
|
19
|
+
desc: "Include remote-tracking branches. Default false (local only).",
|
|
20
|
+
required: false
|
|
21
|
+
|
|
22
|
+
def execute(all: false)
|
|
23
|
+
args = ["branch", "--no-color", "-vv"]
|
|
24
|
+
args << "-a" if all
|
|
25
|
+
|
|
26
|
+
out, err, status = run_git(*args)
|
|
27
|
+
result = git_result(out, err, status)
|
|
28
|
+
return result if result.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
truncate(result.strip.empty? ? "no branches yet (no commits?)" : result)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
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. Switches branches or creates a new branch in the repo at fs_root.
|
|
10
|
+
# The ref is validated (no leading dash, ref-safe characters only) so it
|
|
11
|
+
# can't smuggle git options.
|
|
12
|
+
class GitCheckout < Base
|
|
13
|
+
include GitHelpers
|
|
14
|
+
exec_tool!
|
|
15
|
+
|
|
16
|
+
description "Switch to a branch/commit in the repository at fs_root, or create a new branch " \
|
|
17
|
+
"with create: true. Note this can change the working tree."
|
|
18
|
+
|
|
19
|
+
param :ref, type: "string",
|
|
20
|
+
desc: "Branch, tag, or commit to switch to (or the new branch name when create is true).",
|
|
21
|
+
required: true
|
|
22
|
+
param :create, type: "boolean",
|
|
23
|
+
desc: "Create a new branch named ref before switching to it (git checkout -b). Default false.",
|
|
24
|
+
required: false
|
|
25
|
+
|
|
26
|
+
def execute(ref:, create: false)
|
|
27
|
+
return error("invalid ref: #{ref.inspect}", code: :bad_ref) unless valid_ref?(ref)
|
|
28
|
+
|
|
29
|
+
args = ["checkout"]
|
|
30
|
+
args << "-b" if create
|
|
31
|
+
args << ref
|
|
32
|
+
|
|
33
|
+
out, err, status = run_git(*args)
|
|
34
|
+
result = git_result(out, err, status)
|
|
35
|
+
return result if result.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
# git prints checkout feedback to stderr on success.
|
|
38
|
+
truncate([out, err].map(&:strip).reject(&:empty?).join("\n"))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
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. Commits staged changes in the repo at fs_root. The message is
|
|
10
|
+
# passed as a single argv element (-m), so it is never parsed by a shell.
|
|
11
|
+
class GitCommit < Base
|
|
12
|
+
include GitHelpers
|
|
13
|
+
exec_tool!
|
|
14
|
+
|
|
15
|
+
description "Commit staged changes in the repository at fs_root with a message. Set all to " \
|
|
16
|
+
"also stage modified/deleted tracked files first (git commit -a). Does not push."
|
|
17
|
+
|
|
18
|
+
param :message, type: "string",
|
|
19
|
+
desc: "Commit message.",
|
|
20
|
+
required: true
|
|
21
|
+
param :all, type: "boolean",
|
|
22
|
+
desc: "Stage modified/deleted tracked files before committing (git commit -a). Default false.",
|
|
23
|
+
required: false
|
|
24
|
+
|
|
25
|
+
def execute(message:, all: false)
|
|
26
|
+
msg = message.to_s
|
|
27
|
+
return error("commit message must not be empty", code: :empty_message) if msg.strip.empty?
|
|
28
|
+
|
|
29
|
+
args = ["commit"]
|
|
30
|
+
args << "-a" if all
|
|
31
|
+
args += ["-m", msg]
|
|
32
|
+
|
|
33
|
+
out, err, status = run_git(*args)
|
|
34
|
+
result = git_result(out, err, status)
|
|
35
|
+
if result.is_a?(Hash)
|
|
36
|
+
combined = "#{out}\n#{err}"
|
|
37
|
+
return error("nothing to commit (stage changes first)", code: :nothing_to_commit) if combined.match?(/nothing to commit/i)
|
|
38
|
+
|
|
39
|
+
return result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
truncate(out.strip)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
# SAFE. Shows a unified diff of the repo at fs_root. Read-only.
|
|
10
|
+
#
|
|
11
|
+
# External diff drivers and textconv filters are disabled so a malicious
|
|
12
|
+
# repo can't turn a diff into command execution.
|
|
13
|
+
class GitDiff < Base
|
|
14
|
+
include GitHelpers
|
|
15
|
+
|
|
16
|
+
description "Show a unified git diff for the repository at fs_root. By default shows unstaged " \
|
|
17
|
+
"changes; set staged to show what's staged. Optionally scope to a path or diff " \
|
|
18
|
+
"against a ref. Read-only."
|
|
19
|
+
|
|
20
|
+
param :staged, type: "boolean",
|
|
21
|
+
desc: "Show staged changes (git diff --cached) instead of unstaged. Default false.",
|
|
22
|
+
required: false
|
|
23
|
+
param :path, type: "string",
|
|
24
|
+
desc: "Limit the diff to this path, relative to fs_root. Optional.",
|
|
25
|
+
required: false
|
|
26
|
+
param :ref, type: "string",
|
|
27
|
+
desc: "Diff against this commit/branch/tag instead of the index. Optional.",
|
|
28
|
+
required: false
|
|
29
|
+
|
|
30
|
+
def execute(staged: false, path: nil, ref: nil)
|
|
31
|
+
return error("invalid ref: #{ref.inspect}", code: :bad_ref) if ref && !valid_ref?(ref)
|
|
32
|
+
|
|
33
|
+
rel = repo_relative(path)
|
|
34
|
+
args = ["diff", "--no-ext-diff", "--no-textconv", "--stat", "--patch"]
|
|
35
|
+
args << "--cached" if staged
|
|
36
|
+
args << ref if ref
|
|
37
|
+
args += ["--", rel] if rel
|
|
38
|
+
|
|
39
|
+
out, err, status = run_git(*args)
|
|
40
|
+
result = git_result(out, err, status)
|
|
41
|
+
return result if result.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
truncate(result.strip.empty? ? "no changes" : result)
|
|
44
|
+
rescue Safety::PathJail::Jailbreak => e
|
|
45
|
+
error(e.message, code: :path_denied)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
# SAFE. Searches the repo's tracked files with `git grep` and returns
|
|
10
|
+
# matching lines with file:line prefixes. Read-only. Faster and cleaner
|
|
11
|
+
# than grep_files in a git repo because it only looks at tracked content
|
|
12
|
+
# and skips binary files.
|
|
13
|
+
class GitGrep < Base
|
|
14
|
+
include GitHelpers
|
|
15
|
+
|
|
16
|
+
description "Search the tracked files of the repo at fs_root for a pattern using git grep, " \
|
|
17
|
+
"returning file:line: matches. Optionally restrict to a path, ignore case, or treat " \
|
|
18
|
+
"the pattern as a fixed string instead of a regex."
|
|
19
|
+
|
|
20
|
+
param :pattern, type: "string",
|
|
21
|
+
desc: "The pattern to search for (a basic regex unless fixed is true).",
|
|
22
|
+
required: true
|
|
23
|
+
param :path, type: "string",
|
|
24
|
+
desc: "Restrict the search to this file or directory, relative to fs_root. Optional.",
|
|
25
|
+
required: false
|
|
26
|
+
param :ignore_case, type: "boolean",
|
|
27
|
+
desc: "Case-insensitive search. Default false.",
|
|
28
|
+
required: false
|
|
29
|
+
param :fixed, type: "boolean",
|
|
30
|
+
desc: "Treat the pattern as a literal string rather than a regex. Default false.",
|
|
31
|
+
required: false
|
|
32
|
+
|
|
33
|
+
def execute(pattern:, path: nil, ignore_case: false, fixed: false)
|
|
34
|
+
pat = pattern.to_s
|
|
35
|
+
return error("pattern must not be empty", code: :empty_pattern) if pat.empty?
|
|
36
|
+
|
|
37
|
+
rel = repo_relative(path)
|
|
38
|
+
args = ["grep", "-n", "-I", "--no-color"]
|
|
39
|
+
args << "-i" if ignore_case
|
|
40
|
+
args << "-F" if fixed
|
|
41
|
+
args += ["-e", pat] # -e guards against a pattern that begins with "-"
|
|
42
|
+
args += ["--", rel] if rel
|
|
43
|
+
|
|
44
|
+
out, err, status = run_git(*args)
|
|
45
|
+
interpret(out, err, status)
|
|
46
|
+
rescue Safety::PathJail::Jailbreak => e
|
|
47
|
+
error(e.message, code: :path_denied)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# git grep exits 1 (with no error output) when there are simply no
|
|
53
|
+
# matches — that's a normal result, not a tool failure.
|
|
54
|
+
def interpret(out, err, status)
|
|
55
|
+
return error("timed out after #{config.command_timeout}s", code: :timeout) if status == :timeout
|
|
56
|
+
|
|
57
|
+
case status.exitstatus
|
|
58
|
+
when 0 then truncate(out)
|
|
59
|
+
when 1 then err.to_s.strip.empty? ? "no matches" : error(err.strip, code: :git_error)
|
|
60
|
+
else error(err.to_s.strip.empty? ? out.to_s.strip : err.strip, code: :git_error)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "ruby_llm/toolbox/process_runner"
|
|
5
|
+
require "ruby_llm/toolbox/safety/path_jail"
|
|
6
|
+
|
|
7
|
+
module RubyLLM
|
|
8
|
+
module Toolbox
|
|
9
|
+
module Tools
|
|
10
|
+
# Shared behavior for the git tools. Mixed into each so they all run git
|
|
11
|
+
# the same hardened way.
|
|
12
|
+
#
|
|
13
|
+
# Hardening matters here because git executes commands configured *by the
|
|
14
|
+
# repository* in several places, which turns "read-only" commands into RCE
|
|
15
|
+
# when operating on an untrusted checkout:
|
|
16
|
+
# - core.fsmonitor runs a command during `git status`
|
|
17
|
+
# - diff.external / textconv run commands during `git diff`
|
|
18
|
+
# The read tools are on by default, so the runner neutralizes those
|
|
19
|
+
# (`-c core.fsmonitor=`, and diff adds --no-ext-diff --no-textconv). The
|
|
20
|
+
# pager and credential prompts are also disabled so nothing can hang.
|
|
21
|
+
module GitHelpers
|
|
22
|
+
GIT_ENV = { "GIT_PAGER" => "cat", "GIT_TERMINAL_PROMPT" => "0" }.freeze
|
|
23
|
+
# A ref/branch the model supplies: no leading dash (option injection),
|
|
24
|
+
# and only the characters git refs legitimately use.
|
|
25
|
+
REF_RE = %r{\A[A-Za-z0-9][\w./\-]*\z}
|
|
26
|
+
|
|
27
|
+
def run_git(*args, stdin: nil)
|
|
28
|
+
argv = ["git", "-c", "core.fsmonitor=", "-C", config.fs_root, "--no-pager", *args]
|
|
29
|
+
ProcessRunner.capture(argv, env: git_env, stdin: stdin, timeout: config.command_timeout, unsetenv_others: true)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Maps a finished git run onto the toolbox return contract.
|
|
33
|
+
def git_result(out, err, status)
|
|
34
|
+
return error("git timed out after #{config.command_timeout}s", code: :timeout) if status == :timeout
|
|
35
|
+
return out if status.exitstatus&.zero?
|
|
36
|
+
|
|
37
|
+
message = (err.empty? ? out : err).strip
|
|
38
|
+
code = message.match?(/not a git repository/i) ? :not_a_repo : :git_error
|
|
39
|
+
error("git failed: #{message}", code: code)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def valid_ref?(ref)
|
|
43
|
+
ref.to_s.match?(REF_RE)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Resolve a path param to a repo-relative path, rejecting jail escapes.
|
|
47
|
+
# Returns nil for a blank path. Raises PathJail::Jailbreak on escape.
|
|
48
|
+
def repo_relative(path)
|
|
49
|
+
return nil if path.nil? || path.to_s.empty?
|
|
50
|
+
|
|
51
|
+
jail = Safety::PathJail.new(config.fs_root)
|
|
52
|
+
real = jail.resolve(path)
|
|
53
|
+
Pathname.new(real).relative_path_from(Pathname.new(jail.root)).to_s
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def git_env
|
|
59
|
+
env = config.env_passthrough.each_with_object({}) do |key, acc|
|
|
60
|
+
value = ENV[key]
|
|
61
|
+
acc[key] = value unless value.nil?
|
|
62
|
+
end
|
|
63
|
+
env.merge(GIT_ENV)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
# SAFE. Shows recent commit history for the repo at fs_root. Read-only.
|
|
10
|
+
class GitLog < Base
|
|
11
|
+
include GitHelpers
|
|
12
|
+
|
|
13
|
+
description "Show recent git commit history for the repository at fs_root (hash, date, " \
|
|
14
|
+
"author, subject). Optionally limit the count or scope to a path. Read-only."
|
|
15
|
+
|
|
16
|
+
DEFAULT_COUNT = 20
|
|
17
|
+
MAX_COUNT = 200
|
|
18
|
+
FORMAT = "%h %ad %an: %s"
|
|
19
|
+
|
|
20
|
+
param :count, type: "integer",
|
|
21
|
+
desc: "Number of commits to show (default 20, max 200).",
|
|
22
|
+
required: false
|
|
23
|
+
param :path, type: "string",
|
|
24
|
+
desc: "Limit history to commits touching this path, relative to fs_root. Optional.",
|
|
25
|
+
required: false
|
|
26
|
+
|
|
27
|
+
def execute(count: DEFAULT_COUNT, path: nil)
|
|
28
|
+
n = count.to_i
|
|
29
|
+
n = DEFAULT_COUNT if n <= 0
|
|
30
|
+
n = MAX_COUNT if n > MAX_COUNT
|
|
31
|
+
|
|
32
|
+
rel = repo_relative(path)
|
|
33
|
+
args = ["log", "--max-count=#{n}", "--date=short", "--pretty=format:#{FORMAT}"]
|
|
34
|
+
args += ["--", rel] if rel
|
|
35
|
+
|
|
36
|
+
out, err, status = run_git(*args)
|
|
37
|
+
result = git_result(out, err, status)
|
|
38
|
+
return result if result.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
truncate(result.strip.empty? ? "no commits" : result)
|
|
41
|
+
rescue Safety::PathJail::Jailbreak => e
|
|
42
|
+
error(e.message, code: :path_denied)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
# SAFE. Shows a commit (message + diff) or the contents of a file at a
|
|
10
|
+
# given ref. Read-only. External diff drivers and textconv are disabled.
|
|
11
|
+
class GitShow < Base
|
|
12
|
+
include GitHelpers
|
|
13
|
+
|
|
14
|
+
description "Show a git object in the repo at fs_root. With no path, shows the commit at ref " \
|
|
15
|
+
"(message and diff). With a path, shows that file's contents as of ref. Defaults " \
|
|
16
|
+
"to HEAD. Read-only."
|
|
17
|
+
|
|
18
|
+
param :ref, type: "string",
|
|
19
|
+
desc: "Commit/branch/tag to show. Default HEAD.",
|
|
20
|
+
required: false
|
|
21
|
+
param :path, type: "string",
|
|
22
|
+
desc: "If given, show this file's contents at ref instead of the commit. Optional.",
|
|
23
|
+
required: false
|
|
24
|
+
|
|
25
|
+
def execute(ref: "HEAD", path: nil)
|
|
26
|
+
ref = ref.to_s.strip
|
|
27
|
+
ref = "HEAD" if ref.empty?
|
|
28
|
+
return error("invalid ref: #{ref.inspect}", code: :bad_ref) unless valid_ref?(ref)
|
|
29
|
+
|
|
30
|
+
rel = repo_relative(path)
|
|
31
|
+
args = if rel
|
|
32
|
+
["show", "#{ref}:#{rel}"]
|
|
33
|
+
else
|
|
34
|
+
["show", "--no-ext-diff", "--no-textconv", "--stat", "--patch", ref]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
out, err, status = run_git(*args)
|
|
38
|
+
result = git_result(out, err, status)
|
|
39
|
+
return result if result.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
truncate(result)
|
|
42
|
+
rescue Safety::PathJail::Jailbreak => e
|
|
43
|
+
error(e.message, code: :path_denied)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
# SAFE. Shows the working-tree status of the repo at fs_root (branch plus
|
|
10
|
+
# staged/unstaged/untracked changes). Read-only; requires git on the host.
|
|
11
|
+
class GitStatus < Base
|
|
12
|
+
include GitHelpers
|
|
13
|
+
|
|
14
|
+
description "Show the git working-tree status of the repository at fs_root: current branch " \
|
|
15
|
+
"and staged, unstaged, and untracked changes. Read-only."
|
|
16
|
+
|
|
17
|
+
def execute
|
|
18
|
+
out, err, status = run_git("status", "--short", "--branch")
|
|
19
|
+
result = git_result(out, err, status)
|
|
20
|
+
return result if result.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
truncate(result.strip.empty? ? "working tree clean" : result)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
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. Finds files matching a glob pattern within fs_root and returns
|
|
11
|
+
# their paths relative to the glob base. Each hit is re-checked through
|
|
12
|
+
# the path jail so a symlinked match that points outside the root is
|
|
13
|
+
# dropped.
|
|
14
|
+
class Glob < Base
|
|
15
|
+
description "Find files matching a glob pattern within fs_root (e.g. '**/*.rb', " \
|
|
16
|
+
"'app/models/*.rb'). Returns matching paths, sorted. Results are capped " \
|
|
17
|
+
"and token-budgeted."
|
|
18
|
+
|
|
19
|
+
param :pattern, type: "string",
|
|
20
|
+
desc: "Glob pattern such as '**/*.rb', evaluated relative to base.",
|
|
21
|
+
required: true
|
|
22
|
+
param :base, type: "string",
|
|
23
|
+
desc: "Directory to glob from, relative to fs_root. Defaults to fs_root.",
|
|
24
|
+
required: false
|
|
25
|
+
|
|
26
|
+
MAX_RESULTS = 1_000
|
|
27
|
+
|
|
28
|
+
def execute(pattern:, base: ".")
|
|
29
|
+
return error("pattern must be provided", code: :bad_pattern) if pattern.to_s.empty?
|
|
30
|
+
return error("pattern may not contain '..'", code: :bad_pattern) if pattern.include?("..")
|
|
31
|
+
|
|
32
|
+
jail = Safety::PathJail.new(config.fs_root)
|
|
33
|
+
root = jail.resolve(base)
|
|
34
|
+
return error("not a directory: #{base}", code: :not_a_directory) unless File.directory?(root)
|
|
35
|
+
|
|
36
|
+
base_pn = Pathname.new(root)
|
|
37
|
+
results = Dir.glob(File.join(root, pattern), File::FNM_PATHNAME).sort.filter_map do |match|
|
|
38
|
+
in_jail?(jail, match) ? Pathname.new(match).relative_path_from(base_pn).to_s : nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
capped = results.first(MAX_RESULTS)
|
|
42
|
+
body = +"#{results.size} match#{results.size == 1 ? '' : 'es'}"
|
|
43
|
+
body << " (showing first #{MAX_RESULTS})" if results.size > MAX_RESULTS
|
|
44
|
+
body << " for #{pattern}:\n"
|
|
45
|
+
capped.each { |rel| body << rel << "\n" }
|
|
46
|
+
truncate(body)
|
|
47
|
+
rescue Safety::PathJail::Jailbreak => e
|
|
48
|
+
error(e.message, code: :path_denied)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def in_jail?(jail, match)
|
|
54
|
+
jail.resolve(match)
|
|
55
|
+
File.exist?(match)
|
|
56
|
+
rescue Safety::PathJail::Jailbreak
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|