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
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "helpers"
4
+
5
+ module SharedTools
6
+ module Tools
7
+ module Git
8
+ # Read-only. Lists the branches of the repo at repo_root, with the
9
+ # current branch marked and each branch's latest commit. Does not
10
+ # create, switch, or delete branches (see Git::CheckoutTool).
11
+ #
12
+ # @example
13
+ # tool = SharedTools::Tools::Git::BranchTool.new
14
+ # tool.execute(all: true)
15
+ class BranchTool < ::RubyLLM::Tool
16
+ include Helpers
17
+
18
+ def self.name = 'git_branch'
19
+
20
+ description "List the branches of the repository, with the current branch marked (*) and " \
21
+ "each branch's tip commit. Set all true to include remote-tracking branches. Read-only."
22
+
23
+ parameters do
24
+ boolean :all, description: "Include remote-tracking branches. Default false (local only).", required: false
25
+ end
26
+
27
+ # @param repo_root [String] optional, defaults to the current directory
28
+ # @param logger [Logger] optional logger
29
+ def initialize(repo_root: nil, logger: nil)
30
+ @repo_root = repo_root || Dir.pwd
31
+ @logger = logger || RubyLLM.logger
32
+ end
33
+
34
+ # @param all [Boolean]
35
+ #
36
+ # @return [String, Hash]
37
+ def execute(all: false)
38
+ @logger.info("#{self.class.name}#execute all=#{all}")
39
+
40
+ args = ["branch", "--no-color", "-vv"]
41
+ args << "-a" if all
42
+
43
+ out, err, status = run_git(*args, repo_root: @repo_root)
44
+ result = git_result(out, err, status)
45
+ return result if result.is_a?(Hash)
46
+
47
+ result.strip.empty? ? "no branches yet (no commits?)" : result
48
+ rescue => e
49
+ @logger.error("#{self.class.name} failed: #{e.message}")
50
+ { error: e.message }
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "helpers"
4
+
5
+ module SharedTools
6
+ module Tools
7
+ module Git
8
+ # Mutating. Switches branches or creates a new branch in the repo at
9
+ # repo_root; can change the working tree. The ref is validated (no
10
+ # leading dash, ref-safe characters only) so it can't smuggle git
11
+ # options. Requires user authorization (see SharedTools.execute?).
12
+ #
13
+ # @example
14
+ # tool = SharedTools::Tools::Git::CheckoutTool.new
15
+ # tool.execute(ref: "main")
16
+ # tool.execute(ref: "feature/x", create: true)
17
+ class CheckoutTool < ::RubyLLM::Tool
18
+ include Helpers
19
+
20
+ def self.name = 'git_checkout'
21
+
22
+ description "Switch to a branch/commit in the repository, or create a new branch with " \
23
+ "create: true. Note this can change the working tree."
24
+
25
+ parameters do
26
+ string :ref, description: "Branch, tag, or commit to switch to (or the new branch name when create is true)."
27
+ boolean :create, description: "Create a new branch named ref before switching to it (git checkout -b). Default false.", required: false
28
+ end
29
+
30
+ # @param repo_root [String] optional, defaults to the current directory
31
+ # @param logger [Logger] optional logger
32
+ def initialize(repo_root: nil, logger: nil)
33
+ @repo_root = repo_root || Dir.pwd
34
+ @logger = logger || RubyLLM.logger
35
+ end
36
+
37
+ # @param ref [String]
38
+ # @param create [Boolean]
39
+ #
40
+ # @return [String, Hash]
41
+ def execute(ref:, create: false)
42
+ @logger.info("#{self.class.name}#execute ref=#{ref.inspect} create=#{create}")
43
+
44
+ return { error: "invalid ref: #{ref.inspect}" } unless valid_ref?(ref)
45
+
46
+ args = ["checkout"]
47
+ args << "-b" if create
48
+ args << ref
49
+
50
+ allowed = SharedTools.execute?(tool: self.class.to_s, stuff: args.join(" "))
51
+ unless allowed
52
+ @logger.warn("User declined to checkout #{ref}")
53
+ return { error: "User declined to checkout #{ref}" }
54
+ end
55
+
56
+ out, err, status = run_git(*args, repo_root: @repo_root)
57
+ result = git_result(out, err, status)
58
+ return result if result.is_a?(Hash)
59
+
60
+ # git prints checkout feedback to stderr on success.
61
+ [out, err].map(&:strip).reject(&:empty?).join("\n")
62
+ rescue => e
63
+ @logger.error("#{self.class.name} failed: #{e.message}")
64
+ { error: e.message }
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -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. Commits staged changes in the repo at repo_root with a
9
+ # message. The message is passed as a single argv element (-m), so it
10
+ # is never parsed by a shell. Requires user authorization (see
11
+ # SharedTools.execute?). Does not push.
12
+ #
13
+ # @example
14
+ # tool = SharedTools::Tools::Git::CommitTool.new
15
+ # tool.execute(message: "Fix the thing")
16
+ class CommitTool < ::RubyLLM::Tool
17
+ include Helpers
18
+
19
+ def self.name = 'git_commit'
20
+
21
+ description "Commit staged changes in the repository with a message. Set all to also " \
22
+ "stage modified/deleted tracked files first (git commit -a). Does not push."
23
+
24
+ parameters do
25
+ string :message, description: "Commit message."
26
+ boolean :all, description: "Stage modified/deleted tracked files before committing (git commit -a). Default false.", required: false
27
+ end
28
+
29
+ # @param repo_root [String] optional, defaults to the current directory
30
+ # @param logger [Logger] optional logger
31
+ def initialize(repo_root: nil, logger: nil)
32
+ @repo_root = repo_root || Dir.pwd
33
+ @logger = logger || RubyLLM.logger
34
+ end
35
+
36
+ # @param message [String]
37
+ # @param all [Boolean]
38
+ #
39
+ # @return [String, Hash]
40
+ def execute(message:, all: false)
41
+ @logger.info("#{self.class.name}#execute all=#{all}")
42
+
43
+ msg = message.to_s
44
+ return { error: "commit message must not be empty" } if msg.strip.empty?
45
+
46
+ args = ["commit"]
47
+ args << "-a" if all
48
+ args += ["-m", msg]
49
+
50
+ allowed = SharedTools.execute?(tool: self.class.to_s, stuff: "git commit#{all ? ' -a' : ''} -m #{msg.inspect}")
51
+ unless allowed
52
+ @logger.warn("User declined to commit")
53
+ return { error: "User declined to commit" }
54
+ end
55
+
56
+ out, err, status = run_git(*args, repo_root: @repo_root)
57
+ result = git_result(out, err, status)
58
+ if result.is_a?(Hash)
59
+ combined = "#{out}\n#{err}"
60
+ return { error: "nothing to commit (stage changes first)" } if combined.match?(/nothing to commit/i)
61
+
62
+ return result
63
+ end
64
+
65
+ out.strip
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,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "helpers"
4
+
5
+ module SharedTools
6
+ module Tools
7
+ module Git
8
+ # Read-only. Shows a unified diff of the repo at repo_root. External
9
+ # diff drivers and textconv filters are disabled so a malicious repo
10
+ # can't turn a diff into command execution.
11
+ #
12
+ # @example
13
+ # tool = SharedTools::Tools::Git::DiffTool.new
14
+ # tool.execute
15
+ # tool.execute(staged: true, path: "lib/foo.rb")
16
+ class DiffTool < ::RubyLLM::Tool
17
+ include Helpers
18
+
19
+ def self.name = 'git_diff'
20
+
21
+ description "Show a unified git diff for the repository. By default shows unstaged " \
22
+ "changes; set staged to show what's staged. Optionally scope to a path or diff " \
23
+ "against a ref. Read-only."
24
+
25
+ parameters do
26
+ boolean :staged, description: "Show staged changes (git diff --cached) instead of unstaged. Default false.", required: false
27
+ string :path, description: "Limit the diff to this path, relative to the repo root. Optional.", required: false
28
+ string :ref, description: "Diff against this commit/branch/tag instead of the index. Optional.", required: false
29
+ end
30
+
31
+ # @param repo_root [String] optional, defaults to the current directory
32
+ # @param logger [Logger] optional logger
33
+ def initialize(repo_root: nil, logger: nil)
34
+ @repo_root = repo_root || Dir.pwd
35
+ @logger = logger || RubyLLM.logger
36
+ end
37
+
38
+ # @param staged [Boolean]
39
+ # @param path [String, nil]
40
+ # @param ref [String, nil]
41
+ #
42
+ # @return [String, Hash]
43
+ def execute(staged: false, path: nil, ref: nil)
44
+ @logger.info("#{self.class.name}#execute staged=#{staged} path=#{path.inspect} ref=#{ref.inspect}")
45
+
46
+ return { error: "invalid ref: #{ref.inspect}" } if ref && !valid_ref?(ref)
47
+
48
+ rel = repo_relative(path, repo_root: @repo_root)
49
+ args = ["diff", "--no-ext-diff", "--no-textconv", "--stat", "--patch"]
50
+ args << "--cached" if staged
51
+ args << ref if ref
52
+ args += ["--", rel] if rel
53
+
54
+ out, err, status = run_git(*args, repo_root: @repo_root)
55
+ result = git_result(out, err, status)
56
+ return result if result.is_a?(Hash)
57
+
58
+ result.strip.empty? ? "no changes" : result
59
+ rescue SecurityError => e
60
+ @logger.error("#{self.class.name} path denied: #{e.message}")
61
+ { error: e.message }
62
+ rescue => e
63
+ @logger.error("#{self.class.name} failed: #{e.message}")
64
+ { error: e.message }
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "helpers"
4
+
5
+ module SharedTools
6
+ module Tools
7
+ module Git
8
+ # Read-only. Searches the repo's tracked files with `git grep` and
9
+ # returns matching lines with file:line prefixes. Faster and cleaner
10
+ # than a filesystem grep in a git repo because it only looks at
11
+ # tracked content and skips binary files.
12
+ #
13
+ # @example
14
+ # tool = SharedTools::Tools::Git::GrepTool.new
15
+ # tool.execute(pattern: "def execute")
16
+ class GrepTool < ::RubyLLM::Tool
17
+ include Helpers
18
+
19
+ def self.name = 'git_grep'
20
+
21
+ description "Search the tracked files of the repository for a pattern using git grep, " \
22
+ "returning file:line: matches. Optionally restrict to a path, ignore case, or treat " \
23
+ "the pattern as a fixed string instead of a regex."
24
+
25
+ parameters do
26
+ string :pattern, description: "The pattern to search for (a basic regex unless fixed is true)."
27
+ string :path, description: "Restrict the search to this file or directory, relative to the repo root. Optional.", required: false
28
+ boolean :ignore_case, description: "Case-insensitive search. Default false.", required: false
29
+ boolean :fixed, description: "Treat the pattern as a literal string rather than a regex. Default false.", required: false
30
+ end
31
+
32
+ # @param repo_root [String] optional, defaults to the current directory
33
+ # @param logger [Logger] optional logger
34
+ def initialize(repo_root: nil, logger: nil)
35
+ @repo_root = repo_root || Dir.pwd
36
+ @logger = logger || RubyLLM.logger
37
+ end
38
+
39
+ # @param pattern [String]
40
+ # @param path [String, nil]
41
+ # @param ignore_case [Boolean]
42
+ # @param fixed [Boolean]
43
+ #
44
+ # @return [String, Hash]
45
+ def execute(pattern:, path: nil, ignore_case: false, fixed: false)
46
+ @logger.info("#{self.class.name}#execute pattern=#{pattern.inspect} path=#{path.inspect}")
47
+
48
+ pat = pattern.to_s
49
+ return { error: "pattern must not be empty" } if pat.empty?
50
+
51
+ rel = repo_relative(path, repo_root: @repo_root)
52
+ args = ["grep", "-n", "-I", "--no-color"]
53
+ args << "-i" if ignore_case
54
+ args << "-F" if fixed
55
+ args += ["-e", pat] # -e guards against a pattern that begins with "-"
56
+ args += ["--", rel] if rel
57
+
58
+ out, err, status = run_git(*args, repo_root: @repo_root)
59
+ interpret(out, err, status)
60
+ rescue SecurityError => e
61
+ @logger.error("#{self.class.name} path denied: #{e.message}")
62
+ { error: e.message }
63
+ rescue => e
64
+ @logger.error("#{self.class.name} failed: #{e.message}")
65
+ { error: e.message }
66
+ end
67
+
68
+ private
69
+
70
+ # git grep exits 1 (with no error output) when there are simply no
71
+ # matches — that's a normal result, not a tool failure.
72
+ def interpret(out, err, status)
73
+ case status.exitstatus
74
+ when 0 then out
75
+ when 1 then err.to_s.strip.empty? ? "no matches" : { error: err.strip }
76
+ else { error: err.to_s.strip.empty? ? out.to_s.strip : err.strip }
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "pathname"
5
+
6
+ module SharedTools
7
+ module Tools
8
+ module Git
9
+ # Shared behavior for the git tools: running git the same hardened way,
10
+ # and mapping its output onto the shared_tools { error: } contract.
11
+ #
12
+ # Hardening matters because git executes commands configured *by the
13
+ # repository* in several places, which turns "read-only" commands into
14
+ # arbitrary code execution when operating on an untrusted checkout:
15
+ # - core.fsmonitor runs a command during `git status`
16
+ # - diff.external / textconv run commands during `git diff`
17
+ # core.fsmonitor is disabled via `-c`, and the diff/show tools pass
18
+ # --no-ext-diff --no-textconv. The pager and credential prompts are
19
+ # also disabled so a call can never hang waiting on a TTY.
20
+ module Helpers
21
+ GIT_ENV = { "GIT_PAGER" => "cat", "GIT_TERMINAL_PROMPT" => "0" }.freeze
22
+ # A ref/branch the model supplies: no leading dash (option injection),
23
+ # and only the characters git refs legitimately use.
24
+ REF_RE = %r{\A[A-Za-z0-9][\w./\-]*\z}
25
+
26
+ # @param args [Array<String>]
27
+ # @param repo_root [String]
28
+ # @return [Array(String, String, Process::Status)] stdout, stderr, status
29
+ def run_git(*args, repo_root:)
30
+ Open3.capture3(GIT_ENV, "git", "-c", "core.fsmonitor=", "-C", repo_root.to_s, "--no-pager", *args)
31
+ end
32
+
33
+ # Maps a finished git run onto the shared_tools return contract.
34
+ #
35
+ # @return [String, Hash] stdout on success, or an { error:, code: } hash
36
+ def git_result(out, err, status)
37
+ return out if status.success?
38
+
39
+ message = (err.to_s.empty? ? out.to_s : err.to_s).strip
40
+ code = message.match?(/not a git repository/i) ? :not_a_repo : :git_error
41
+ { error: message.empty? ? "git failed with exit code #{status.exitstatus}" : "git failed: #{message}", code: code }
42
+ end
43
+
44
+ # @param ref [String]
45
+ # @return [Boolean]
46
+ def valid_ref?(ref)
47
+ ref.to_s.match?(REF_RE)
48
+ end
49
+
50
+ # Resolve a path param to a repo-relative path, rejecting jail escapes.
51
+ # Returns nil for a blank path.
52
+ #
53
+ # @param path [String, nil]
54
+ # @param repo_root [String]
55
+ #
56
+ # @raise [SecurityError] if the path escapes repo_root
57
+ # @return [String, nil]
58
+ def repo_relative(path, repo_root:)
59
+ return nil if path.nil? || path.to_s.strip.empty?
60
+
61
+ root = Pathname.new(File.expand_path(repo_root))
62
+ resolved = (root + path).cleanpath
63
+ raise SecurityError, "path escapes repo_root: #{path}" unless resolved.ascend.any? { |ancestor| ancestor == root }
64
+
65
+ resolved.relative_path_from(root).to_s
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "helpers"
4
+
5
+ module SharedTools
6
+ module Tools
7
+ module Git
8
+ # Read-only. Shows recent commit history for the repo at repo_root.
9
+ #
10
+ # @example
11
+ # tool = SharedTools::Tools::Git::LogTool.new
12
+ # tool.execute(count: 10)
13
+ class LogTool < ::RubyLLM::Tool
14
+ include Helpers
15
+
16
+ DEFAULT_COUNT = 20
17
+ MAX_COUNT = 200
18
+ FORMAT = "%h %ad %an: %s"
19
+
20
+ def self.name = 'git_log'
21
+
22
+ description "Show recent git commit history for the repository (hash, date, author, " \
23
+ "subject). Optionally limit the count or scope to a path. Read-only."
24
+
25
+ parameters do
26
+ integer :count, description: "Number of commits to show (default #{DEFAULT_COUNT}, max #{MAX_COUNT}).", required: false
27
+ string :path, description: "Limit history to commits touching this path, relative to the repo root. Optional.", required: false
28
+ end
29
+
30
+ # @param repo_root [String] optional, defaults to the current directory
31
+ # @param logger [Logger] optional logger
32
+ def initialize(repo_root: nil, logger: nil)
33
+ @repo_root = repo_root || Dir.pwd
34
+ @logger = logger || RubyLLM.logger
35
+ end
36
+
37
+ # @param count [Integer]
38
+ # @param path [String, nil]
39
+ #
40
+ # @return [String, Hash]
41
+ def execute(count: DEFAULT_COUNT, path: nil)
42
+ @logger.info("#{self.class.name}#execute count=#{count} path=#{path.inspect}")
43
+
44
+ n = count.to_i
45
+ n = DEFAULT_COUNT if n <= 0
46
+ n = MAX_COUNT if n > MAX_COUNT
47
+
48
+ rel = repo_relative(path, repo_root: @repo_root)
49
+ args = ["log", "--max-count=#{n}", "--date=short", "--pretty=format:#{FORMAT}"]
50
+ args += ["--", rel] if rel
51
+
52
+ out, err, status = run_git(*args, repo_root: @repo_root)
53
+ result = git_result(out, err, status)
54
+ if result.is_a?(Hash)
55
+ return "no commits" if result[:error].to_s.match?(/does not have any commits yet|bad default revision/i)
56
+
57
+ return result
58
+ end
59
+
60
+ result.strip.empty? ? "no commits" : result
61
+ rescue SecurityError => e
62
+ @logger.error("#{self.class.name} path denied: #{e.message}")
63
+ { error: e.message }
64
+ rescue => e
65
+ @logger.error("#{self.class.name} failed: #{e.message}")
66
+ { error: e.message }
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "helpers"
4
+
5
+ module SharedTools
6
+ module Tools
7
+ module Git
8
+ # Read-only. Shows a commit (message + diff) or the contents of a file
9
+ # at a given ref. External diff drivers and textconv are disabled.
10
+ #
11
+ # @example
12
+ # tool = SharedTools::Tools::Git::ShowTool.new
13
+ # tool.execute
14
+ # tool.execute(ref: "HEAD~1", path: "lib/foo.rb")
15
+ class ShowTool < ::RubyLLM::Tool
16
+ include Helpers
17
+
18
+ def self.name = 'git_show'
19
+
20
+ description "Show a git object in the repository. With no path, shows the commit at ref " \
21
+ "(message and diff). With a path, shows that file's contents as of ref. Defaults " \
22
+ "to HEAD. Read-only."
23
+
24
+ parameters do
25
+ string :ref, description: "Commit/branch/tag to show. Default HEAD.", required: false
26
+ string :path, description: "If given, show this file's contents at ref instead of the commit. Optional.", required: false
27
+ end
28
+
29
+ # @param repo_root [String] optional, defaults to the current directory
30
+ # @param logger [Logger] optional logger
31
+ def initialize(repo_root: nil, logger: nil)
32
+ @repo_root = repo_root || Dir.pwd
33
+ @logger = logger || RubyLLM.logger
34
+ end
35
+
36
+ # @param ref [String]
37
+ # @param path [String, nil]
38
+ #
39
+ # @return [String, Hash]
40
+ def execute(ref: "HEAD", path: nil)
41
+ @logger.info("#{self.class.name}#execute ref=#{ref.inspect} path=#{path.inspect}")
42
+
43
+ ref = ref.to_s.strip
44
+ ref = "HEAD" if ref.empty?
45
+ return { error: "invalid ref: #{ref.inspect}" } unless valid_ref?(ref)
46
+
47
+ rel = repo_relative(path, repo_root: @repo_root)
48
+ args = if rel
49
+ ["show", "#{ref}:#{rel}"]
50
+ else
51
+ ["show", "--no-ext-diff", "--no-textconv", "--stat", "--patch", ref]
52
+ end
53
+
54
+ out, err, status = run_git(*args, repo_root: @repo_root)
55
+ git_result(out, err, status)
56
+ rescue SecurityError => e
57
+ @logger.error("#{self.class.name} path denied: #{e.message}")
58
+ { error: e.message }
59
+ rescue => e
60
+ @logger.error("#{self.class.name} failed: #{e.message}")
61
+ { error: e.message }
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "helpers"
4
+
5
+ module SharedTools
6
+ module Tools
7
+ module Git
8
+ # Read-only. Shows the working-tree status of the repo at repo_root
9
+ # (current branch plus staged/unstaged/untracked changes).
10
+ #
11
+ # @example
12
+ # tool = SharedTools::Tools::Git::StatusTool.new
13
+ # tool.execute
14
+ class StatusTool < ::RubyLLM::Tool
15
+ include Helpers
16
+
17
+ def self.name = 'git_status'
18
+
19
+ description "Show the git working-tree status of the repository: current branch and " \
20
+ "staged, unstaged, and untracked changes. Read-only."
21
+
22
+ # @param repo_root [String] optional, defaults to the current directory
23
+ # @param logger [Logger] optional logger
24
+ def initialize(repo_root: nil, logger: nil)
25
+ @repo_root = repo_root || Dir.pwd
26
+ @logger = logger || RubyLLM.logger
27
+ end
28
+
29
+ # @return [String, Hash]
30
+ def execute
31
+ @logger.info("#{self.class.name}#execute repo_root=#{@repo_root}")
32
+
33
+ out, err, status = run_git("status", "--short", "--branch", repo_root: @repo_root)
34
+ result = git_result(out, err, status)
35
+ return result if result.is_a?(Hash)
36
+
37
+ changes = result.each_line.reject { |line| line.start_with?("##") }
38
+ changes.empty? ? "working tree clean" : result
39
+ rescue => e
40
+ @logger.error("#{self.class.name} failed: #{e.message}")
41
+ { error: e.message }
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Collection loader for all git tools
4
+ # Usage: require 'shared_tools/tools/git'
5
+
6
+ require 'shared_tools'
7
+
8
+ require_relative 'git/helpers'
9
+ require_relative 'git/status_tool'
10
+ require_relative 'git/diff_tool'
11
+ require_relative 'git/log_tool'
12
+ require_relative 'git/show_tool'
13
+ require_relative 'git/blame_tool'
14
+ require_relative 'git/branch_tool'
15
+ require_relative 'git/grep_tool'
16
+ require_relative 'git/add_tool'
17
+ require_relative 'git/commit_tool'
18
+ require_relative 'git/checkout_tool'