agent_c 2.71828

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 (62) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +10 -0
  3. data/.ruby-version +1 -0
  4. data/CLAUDE.md +21 -0
  5. data/README.md +360 -0
  6. data/Rakefile +16 -0
  7. data/TODO.md +12 -0
  8. data/agent_c.gemspec +38 -0
  9. data/docs/chat-methods.md +157 -0
  10. data/docs/cost-reporting.md +86 -0
  11. data/docs/pipeline-tips-and-tricks.md +71 -0
  12. data/docs/session-configuration.md +274 -0
  13. data/docs/testing.md +747 -0
  14. data/docs/tools.md +103 -0
  15. data/docs/versioned-store.md +840 -0
  16. data/lib/agent_c/agent/chat.rb +211 -0
  17. data/lib/agent_c/agent/chat_response.rb +32 -0
  18. data/lib/agent_c/agent/chats/anthropic_bedrock.rb +48 -0
  19. data/lib/agent_c/batch.rb +102 -0
  20. data/lib/agent_c/configs/repo.rb +90 -0
  21. data/lib/agent_c/context.rb +56 -0
  22. data/lib/agent_c/costs/data.rb +39 -0
  23. data/lib/agent_c/costs/report.rb +219 -0
  24. data/lib/agent_c/db/store.rb +162 -0
  25. data/lib/agent_c/errors.rb +19 -0
  26. data/lib/agent_c/pipeline.rb +188 -0
  27. data/lib/agent_c/processor.rb +98 -0
  28. data/lib/agent_c/prompts.yml +53 -0
  29. data/lib/agent_c/schema.rb +85 -0
  30. data/lib/agent_c/session.rb +207 -0
  31. data/lib/agent_c/store.rb +72 -0
  32. data/lib/agent_c/test_helpers.rb +173 -0
  33. data/lib/agent_c/tools/dir_glob.rb +46 -0
  34. data/lib/agent_c/tools/edit_file.rb +112 -0
  35. data/lib/agent_c/tools/file_metadata.rb +43 -0
  36. data/lib/agent_c/tools/grep.rb +119 -0
  37. data/lib/agent_c/tools/paths.rb +36 -0
  38. data/lib/agent_c/tools/read_file.rb +94 -0
  39. data/lib/agent_c/tools/run_rails_test.rb +87 -0
  40. data/lib/agent_c/tools.rb +60 -0
  41. data/lib/agent_c/utils/git.rb +75 -0
  42. data/lib/agent_c/utils/shell.rb +58 -0
  43. data/lib/agent_c/version.rb +5 -0
  44. data/lib/agent_c.rb +32 -0
  45. data/lib/versioned_store/base.rb +314 -0
  46. data/lib/versioned_store/config.rb +26 -0
  47. data/lib/versioned_store/stores/schema.rb +127 -0
  48. data/lib/versioned_store/version.rb +5 -0
  49. data/lib/versioned_store.rb +5 -0
  50. data/template/Gemfile +9 -0
  51. data/template/Gemfile.lock +152 -0
  52. data/template/README.md +61 -0
  53. data/template/Rakefile +50 -0
  54. data/template/bin/rake +27 -0
  55. data/template/lib/autoload.rb +10 -0
  56. data/template/lib/config.rb +59 -0
  57. data/template/lib/pipeline.rb +19 -0
  58. data/template/lib/prompts.yml +57 -0
  59. data/template/lib/store.rb +17 -0
  60. data/template/test/pipeline_test.rb +221 -0
  61. data/template/test/test_helper.rb +18 -0
  62. metadata +191 -0
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ module Tools
5
+ class EditFile < RubyLLM::Tool
6
+ description "Edits a file with various operations: overwrite entire file, replace specific lines, insert at position, or append"
7
+
8
+ params do
9
+ string(
10
+ :path,
11
+ description: "Path to file. Must be a child of current directory.",
12
+ required: true
13
+ )
14
+ string(
15
+ :mode,
16
+ description: "Operation mode: 'overwrite' (replace entire file), 'replace_lines' (replace line range), 'insert_at_line' (insert before specified line), 'append' (add to end)",
17
+ enum: ["overwrite", "replace_lines", "insert_at_line", "append"],
18
+ required: true
19
+ )
20
+ string(
21
+ :content,
22
+ description: "The content to write, insert, or append. Can be multi-line.",
23
+ required: true
24
+ )
25
+ integer(
26
+ :start_line,
27
+ description: <<~TXT,
28
+ For replace_lines: first line to replace, inclusive (1-indexed).
29
+ For insert_at_line: line before which to insert (1=start of file, 999999=end of file).
30
+ TXT
31
+ required: false
32
+ )
33
+ integer(
34
+ :end_line,
35
+ description: "For replace_lines only: last line to replace, inclusive (1-indexed).",
36
+ required: false
37
+ )
38
+ end
39
+
40
+ attr_reader :workspace_dir
41
+ def initialize(workspace_dir: nil, **)
42
+ raise ArgumentError, "workspace_dir is required" unless workspace_dir
43
+ @workspace_dir = workspace_dir
44
+ end
45
+
46
+ def execute(path:, mode:, content:, start_line: nil, end_line: nil, **params)
47
+ if params.any?
48
+ return "The following params were passed but are not allowed: #{params.keys.join(",")}"
49
+ end
50
+
51
+ unless Paths.allowed?(workspace_dir, path)
52
+ return "Path: #{path} not acceptable. Must be a child of directory: #{workspace_dir}."
53
+ end
54
+
55
+ workspace_path = Paths.relative_to_dir(workspace_dir, path)
56
+
57
+ case mode
58
+ when "overwrite"
59
+ FileUtils.mkdir_p(File.dirname(workspace_path))
60
+ File.write(workspace_path, content)
61
+
62
+ when "replace_lines"
63
+ return "start_line and end_line required for replace_lines mode" unless start_line && end_line
64
+ return "start_line must be <= end_line" if start_line > end_line
65
+
66
+ lines = File.exist?(workspace_path) ? File.readlines(workspace_path, chomp: true) : []
67
+
68
+ # Convert to 0-indexed
69
+ start_idx = start_line - 1
70
+ end_idx = end_line - 1
71
+
72
+ # Replace the range with new content lines
73
+ new_lines = content.split("\n")
74
+ lines[start_idx..end_idx] = new_lines
75
+
76
+ File.write(workspace_path, lines.join("\n") + "\n")
77
+
78
+ when "insert_at_line"
79
+ return "start_line required for insert_at_line mode" unless start_line
80
+
81
+ lines = File.exist?(workspace_path) ? File.readlines(workspace_path, chomp: true) : []
82
+
83
+ # Insert before the specified line (1-indexed)
84
+ insert_idx = start_line - 1
85
+ insert_idx = [0, [insert_idx, lines.length].min].max # Clamp to valid range
86
+
87
+ new_lines = content.split("\n")
88
+ lines.insert(insert_idx, *new_lines)
89
+
90
+ File.write(workspace_path, lines.join("\n") + "\n")
91
+
92
+ when "append"
93
+ FileUtils.mkdir_p(File.dirname(workspace_path))
94
+ File.open(workspace_path, "a") do |f|
95
+ f.write(content)
96
+ f.write("\n") unless content.end_with?("\n")
97
+ end
98
+ end
99
+
100
+ # Run syntax check for Ruby files
101
+ if workspace_path.end_with?(".rb")
102
+ syntax_check = `ruby -c #{workspace_path} 2>&1`
103
+ unless $?.success?
104
+ return "File successfully edited, but syntax errors were found:\n#{syntax_check}"
105
+ end
106
+ end
107
+
108
+ true
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ module Tools
5
+ class FileMetadata < RubyLLM::Tool
6
+ description "Returns metadata of a file, including line-count, mtime"
7
+
8
+ params do
9
+ string(
10
+ :path,
11
+ description: "Path to file. Must be a child of current directory."
12
+ )
13
+ end
14
+
15
+ attr_reader :workspace_dir
16
+ def initialize(workspace_dir: nil, **)
17
+ raise ArgumentError, "workspace_dir is required" unless workspace_dir
18
+ @workspace_dir = workspace_dir
19
+ end
20
+
21
+ def execute(path:, line_range_start: 0, line_range_end: nil, **params)
22
+ if params.any?
23
+ return "The following params were passed but are not allowed: #{params.keys.join(",")}"
24
+ end
25
+
26
+ unless Paths.allowed?(workspace_dir, path)
27
+ return "Path: #{path} not acceptable. Must be a child of directory: #{workspace_dir}."
28
+ end
29
+
30
+ workspace_path = Paths.relative_to_dir(workspace_dir, path)
31
+
32
+ unless File.exist?(workspace_path)
33
+ return "File not found"
34
+ end
35
+
36
+ {
37
+ mtime: File.mtime(workspace_path),
38
+ lines: File.foreach(workspace_path).count,
39
+ }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module AgentC
6
+ module Tools
7
+ class Grep < RubyLLM::Tool
8
+ description <<~DESC
9
+ Searches for patterns in files using git grep. Returns matching lines
10
+ with file paths and line numbers.
11
+ DESC
12
+
13
+ params do
14
+ string(
15
+ :pattern,
16
+ description: <<~DESC,
17
+ The regex pattern to search for. Use standard regex syntax.
18
+ DESC
19
+ required: true
20
+ )
21
+ string(
22
+ :file_pattern,
23
+ description: <<~DESC,
24
+ Optional glob pattern to limit search to specific files
25
+ (e.g., '*.rb', 'app/**/*.js'). If omitted, searches all files.
26
+ DESC
27
+ required: false
28
+ )
29
+ boolean(
30
+ :ignore_case,
31
+ description: <<~DESC,
32
+ If true, performs case-insensitive search. Default is false.
33
+ DESC
34
+ required: false
35
+ )
36
+ integer(
37
+ :context_lines,
38
+ description: <<~DESC,
39
+ Number of context lines to show before and after each match.
40
+ Default is 0.
41
+ DESC
42
+ required: false
43
+ )
44
+ integer(
45
+ :line_range_start,
46
+ description: <<~DESC,
47
+ Return results starting from this line number (1-indexed).
48
+ Default is 1.
49
+ DESC
50
+ required: false
51
+ )
52
+ integer(
53
+ :line_range_end,
54
+ description: <<~DESC,
55
+ Return results up to and including this line number (1-indexed).
56
+ If omitted, returns to the end.
57
+ DESC
58
+ required: false
59
+ )
60
+ end
61
+
62
+ attr_reader :workspace_dir
63
+ def initialize(workspace_dir: nil, **)
64
+ raise ArgumentError, "workspace_dir is required" unless workspace_dir
65
+ @workspace_dir = workspace_dir
66
+ end
67
+
68
+ def execute(pattern:, file_pattern: nil, ignore_case: false, context_lines: 0, line_range_start: 1, line_range_end: nil, **params)
69
+ if params.any?
70
+ return "The following params were passed but are not allowed: #{params.keys.join(",")}"
71
+ end
72
+
73
+ Dir.chdir(workspace_dir) do
74
+
75
+ cmd = ["git", "grep", "-n", "--extended-regexp"] # -n shows line numbers
76
+
77
+ cmd << "-i" if ignore_case
78
+ cmd << "-C" << context_lines.to_s if context_lines > 0
79
+
80
+ cmd << "-e" << pattern
81
+
82
+ if file_pattern
83
+ cmd << "--" << file_pattern
84
+ end
85
+
86
+
87
+ stdout, stderr, status = Open3.capture3(*cmd)
88
+
89
+ if status.success?
90
+ lines = stdout.force_encoding("UTF-8").split("\n")
91
+
92
+ # Apply line range (convert to 0-indexed)
93
+ start_idx = [line_range_start - 1, 0].max
94
+ end_idx = line_range_end ? line_range_end - 1 : -1
95
+ lines = lines[start_idx..end_idx] || []
96
+
97
+ lines = lines.map { |l| l[0..100]}
98
+
99
+ total_lines = lines.length
100
+ max_lines = 300
101
+
102
+ if total_lines > max_lines
103
+ limited_content = lines[0...max_lines].join("\n")
104
+ return "Returning #{max_lines} lines out of #{total_lines} total lines:\n\n#{limited_content}"
105
+ else
106
+ return lines.join("\n")
107
+ end
108
+ elsif status.exitstatus == 1
109
+ # Exit status 1 means no matches found
110
+ return "No matches found."
111
+ else
112
+ # Other error
113
+ return "Error: #{stderr}"
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module AgentC
6
+ module Tools
7
+ module Paths
8
+ module_function
9
+
10
+ def relative_to_dir(dir, path)
11
+ if path.start_with?(dir)
12
+ path
13
+ else
14
+ File.join(dir, path)
15
+ end
16
+ end
17
+
18
+ def allowed?(dir, path)
19
+ child?(dir, path)
20
+ end
21
+
22
+ def child?(parent_dir, child_path)
23
+
24
+ unless child_path.start_with?("/")
25
+ child_path = File.join(parent_dir, child_path)
26
+ end
27
+
28
+ child = Pathname.new(child_path).expand_path
29
+ parent = Pathname.new(parent_dir).expand_path
30
+
31
+ relative = child.relative_path_from(parent)
32
+ !relative.to_s.start_with?('..')
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ raise "here" if defined?(AgentC::Tools::ReadFile)
4
+
5
+ module AgentC
6
+ module Tools
7
+ class ReadFile < RubyLLM::Tool
8
+ description "Reads the contents of a file"
9
+
10
+ params do
11
+ string(
12
+ :path,
13
+ description: <<~TXT
14
+ Path to file. Must be a child of current directory.
15
+
16
+ Only returns 300 lines at a time. Specify line range if possible
17
+ to avoid truncated responses.
18
+ TXT
19
+ )
20
+
21
+ number(
22
+ :line_range_start,
23
+ description: "Read lines on or after this line number.",
24
+ required: false
25
+ )
26
+
27
+ number(
28
+ :line_range_end,
29
+ description: "Read lines on or before this line number.",
30
+ required: false
31
+ )
32
+ end
33
+
34
+ attr_reader :workspace_dir
35
+ def initialize(workspace_dir: nil, **)
36
+ raise ArgumentError, "workspace_dir is required" unless workspace_dir
37
+ @workspace_dir = workspace_dir
38
+ end
39
+
40
+
41
+ def execute(path:, line_range_start: 0, line_range_end: nil, **params)
42
+ if params.any?
43
+ return "The following params were passed but are not allowed: #{params.keys.join(",")}"
44
+ end
45
+
46
+ unless Paths.allowed?(workspace_dir, path)
47
+ return "Path: #{path} not acceptable. Must be a child of directory: #{workspace_dir}."
48
+ end
49
+
50
+ workspace_path = Paths.relative_to_dir(workspace_dir, path)
51
+
52
+ unless File.exist?(workspace_path)
53
+ return "File not found"
54
+ end
55
+
56
+ all_lines = File.read(workspace_path).split("\n")
57
+ lines = all_lines[line_range_start..line_range_end]
58
+
59
+ total_lines = lines.length
60
+ max_lines = 300
61
+
62
+ # Determine the actual line range being returned (1-indexed for display)
63
+ actual_start = line_range_start + 1
64
+ actual_end = line_range_end ? [line_range_end + 1, all_lines.length].min : all_lines.length
65
+
66
+ if total_lines > max_lines
67
+ limited_content = format_with_line_numbers(lines[0...max_lines], actual_start)
68
+ limited_end = actual_start + max_lines - 1
69
+ "Returning lines #{actual_start}-#{limited_end}, out of #{total_lines} total lines:\n\n#{limited_content}"
70
+ elsif line_range_start > 0 || line_range_end
71
+ # Line range was specified
72
+ formatted_content = format_with_line_numbers(lines, actual_start)
73
+ "Returning lines #{actual_start}-#{actual_end}, out of #{all_lines.length} total lines:\n\n#{formatted_content}"
74
+ else
75
+ format_with_line_numbers(lines, 1)
76
+ end
77
+ rescue => e
78
+ "File read error: #{e.class}:#{e.message}"
79
+ end
80
+
81
+ private
82
+
83
+ # Format lines with line numbers
84
+ def format_with_line_numbers(lines, starting_line_num)
85
+ max_line_num = starting_line_num + lines.length - 1
86
+ width = max_line_num.to_s.length
87
+ lines.map.with_index do |line, idx|
88
+ line_num = starting_line_num + idx
89
+ "%#{width}d: %s" % [line_num, line]
90
+ end.join("\n")
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module AgentC
6
+ module Tools
7
+ class RunRailsTest < RubyLLM::Tool
8
+ description "Runs a minitest rails test using bin/rails test {path} --name={name_of_test_method}"
9
+
10
+ params do
11
+ string(
12
+ :path,
13
+ description: "Path to file. Must be a child of current directory.",
14
+ required: true
15
+ )
16
+ string(
17
+ :test_method_name,
18
+ description: "The name of the specific test method to run",
19
+ required: false
20
+ )
21
+ boolean(
22
+ :disable_spring,
23
+ description: <<~TXT,
24
+ Disable spring if the errors are weird and you want to make sure
25
+ it's not a spring issue. Prefer to use spring unless you are
26
+ encountering weird behavior.
27
+ TXT
28
+ required: false
29
+ )
30
+ end
31
+
32
+ attr_reader :workspace_dir, :env
33
+ def initialize(
34
+ workspace_dir: nil,
35
+ env: {},
36
+ **
37
+ )
38
+ raise ArgumentError, "workspace_dir is required" unless workspace_dir
39
+ @env = env
40
+ @workspace_dir = workspace_dir
41
+ end
42
+
43
+ def execute(path:, test_method_name: nil, disable_spring: false, **params)
44
+
45
+ # Spring hangs, need to timeout unresponsive shells
46
+ disable_spring = true
47
+
48
+ if params.any?
49
+ return "The following params were passed but are not allowed: #{params.keys.join(",")}"
50
+ end
51
+
52
+ unless Paths.allowed?(workspace_dir, path)
53
+ return "Path: #{path} not acceptable. Must be a child of directory: #{workspace_dir}."
54
+ end
55
+
56
+ workspace_path = Paths.relative_to_dir(workspace_dir, path)
57
+
58
+ env_string = env.is_a?(Hash) ? env.map { |k, v| "#{k}=#{Shellwords.escape(v)}"}.join(" ") : env
59
+
60
+ env_string += " DISABLE_SPRING=1" if disable_spring
61
+
62
+ cmd = <<~TXT.chomp
63
+ cd #{workspace_dir} && \
64
+ #{env_string} bundle exec rails test #{path} #{test_method_name && "--name='#{test_method_name}'"}
65
+ TXT
66
+
67
+ lines = []
68
+ result = nil
69
+ Bundler.with_unbundled_env do
70
+ result = shell.run(cmd) do |stream, line|
71
+ lines << "[#{stream}] #{line}"
72
+ end
73
+ end
74
+
75
+ return <<~TXT
76
+ Command exited #{result.success? ? "successfully" : "with non-zero exit code"}
77
+ ---
78
+ #{lines.join("\n")}
79
+ TXT
80
+ end
81
+
82
+ def shell
83
+ AgentC::Utils::Shell
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ module Tools
5
+ NAMES = {
6
+ read_file: ReadFile,
7
+ edit_file: EditFile,
8
+ grep: Grep,
9
+ file_metadata: FileMetadata,
10
+ dir_glob: DirGlob,
11
+ run_rails_test: RunRailsTest,
12
+ }
13
+
14
+ def self.all(**params)
15
+ NAMES.values.map { _1.new(**params) }
16
+ end
17
+
18
+ def self.resolve(value:, available_tools:, args:, workspace_dir:)
19
+
20
+ # ensure any args passed have a
21
+ # workspace_dir.
22
+ resolved_args = (
23
+ if args.key?(:workspace_dir)
24
+ args
25
+ else
26
+ args.merge(workspace_dir:)
27
+ end
28
+ )
29
+
30
+ # If they passed a tool instance, nothing to do
31
+ if value.is_a?(RubyLLM::Tool)
32
+ return value
33
+ elsif value.is_a?(Symbol) || value.is_a?(String)
34
+ # They passed the tool name
35
+ # we must initialize it with
36
+ # the standard args
37
+ tool_name = value.to_sym
38
+ unless available_tools.key?(tool_name)
39
+ raise ArgumentError, <<~TXT
40
+ Unknown tool name: #{value.inspect}.
41
+ If you wish to use a custom tool you must configure
42
+ it by passing `extra_tools` to the Session.
43
+ TXT
44
+ end
45
+
46
+ klass_or_instance = available_tools.fetch(tool_name)
47
+
48
+ if klass_or_instance.is_a?(RubyLLM::Tool)
49
+ klass_or_instance
50
+ else
51
+ klass_or_instance.new(**resolved_args)
52
+ end
53
+ elsif value.is_a?(Class) && value.ancestors.include?(RubyLLM::Tool)
54
+ value.new(**resolved_args)
55
+ else
56
+ raise ArgumentError, "unknown tool specified: #{value.inspect}"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module AgentC
6
+ module Utils
7
+ # Git utility class for managing Git operations in worktrees
8
+ class Git
9
+ attr_reader :repo_path
10
+
11
+ def initialize(repo_path)
12
+ @repo_path = repo_path
13
+ end
14
+
15
+ def create_worktree(dir:, branch:, revision:)
16
+ # Prune any stale worktrees first
17
+ shell.run!("cd #{repo_path} && git worktree prune")
18
+
19
+ # Remove worktree at dir if it exists (don't fail if it doesn't exist)
20
+ shell.run!("cd #{repo_path} && (git worktree remove #{Shellwords.escape(dir)} --force 2>/dev/null || true)")
21
+
22
+ shell.run!(
23
+ <<~TXT
24
+ cd #{repo_path} && \
25
+ git worktree add \
26
+ -B #{Shellwords.escape(branch)} \
27
+ #{Shellwords.escape(dir)} \
28
+ #{Shellwords.escape(revision)}
29
+ TXT
30
+ )
31
+ end
32
+
33
+ def diff
34
+ shell.run!("cd #{repo_path} && git diff")
35
+ end
36
+
37
+ def last_revision
38
+ shell.run!("cd #{repo_path} && git rev-parse @").strip
39
+ end
40
+
41
+ def commit_all(message)
42
+ shell.run!("cd #{repo_path} && git add --all && git commit --no-gpg-sign -m #{Shellwords.escape(message)}")
43
+ last_revision
44
+ end
45
+
46
+ def fixup_commit(revision)
47
+ shell.run!("cd #{repo_path} && git add --all && git commit --no-gpg-sign --fixup #{revision}")
48
+ last_revision
49
+ end
50
+
51
+ def reset_hard_all
52
+ shell.run!("cd #{repo_path} && git add --all && git reset --hard")
53
+ end
54
+
55
+ def clean?
56
+ !uncommitted_changes?
57
+ end
58
+
59
+ def uncommitted_changes?
60
+ # Check for any changes including untracked files
61
+ # Returns true if there are uncommitted changes (staged, unstaged, or untracked)
62
+ status = shell.run!("cd #{repo_path} && git status --porcelain")
63
+ !status.strip.empty?
64
+ end
65
+
66
+ private
67
+
68
+ def shell
69
+ AgentC::Utils::Shell
70
+ end
71
+ end
72
+
73
+ # TODO: Add more utility classes here as needed
74
+ end
75
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "open3"
5
+
6
+ module AgentC
7
+ module Utils
8
+ class Shell
9
+ Error = Class.new(StandardError)
10
+ class << self
11
+ Result = Struct.new(:command, :success, :stdout, :stderr, keyword_init: true) do
12
+ def success?
13
+ success
14
+ end
15
+ end
16
+
17
+ def run!(...)
18
+ # only returns stdout just because.
19
+ run(...)
20
+ .tap { raise "command failed: \n#{_1.inspect}" unless _1.success? }
21
+ .stdout
22
+ end
23
+
24
+ def run(command)
25
+ Open3.popen3(command) do |_in, stdout, stderr, wait_thr|
26
+ process_stdout = []
27
+ stdout_thr = Thread.new do
28
+ while line = stdout.gets&.chomp
29
+ yield(:stdout, line) if block_given?
30
+ process_stdout << line
31
+ end
32
+ end
33
+
34
+ process_stderr = []
35
+ stderr_thr = Thread.new do
36
+ while line = stderr.gets&.chomp
37
+ yield(:stderr, line) if block_given?
38
+ process_stderr << line
39
+ end
40
+ end
41
+
42
+ [
43
+ stderr_thr,
44
+ stdout_thr,
45
+ ].each(&:join)
46
+
47
+ Result.new(
48
+ command: command,
49
+ success: wait_thr.value.success?,
50
+ stdout: process_stdout.join("\n"),
51
+ stderr: process_stderr.join("\n"),
52
+ )
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgentC
4
+ VERSION = "2.71828"
5
+ end