agent-harness 0.2.1

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 (41) hide show
  1. checksums.yaml +7 -0
  2. data/.markdownlint.yml +6 -0
  3. data/.markdownlintignore +8 -0
  4. data/.release-please-manifest.json +3 -0
  5. data/.rspec +3 -0
  6. data/.simplecov +26 -0
  7. data/.tool-versions +1 -0
  8. data/CHANGELOG.md +27 -0
  9. data/CODE_OF_CONDUCT.md +10 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +274 -0
  12. data/Rakefile +103 -0
  13. data/bin/console +11 -0
  14. data/bin/setup +8 -0
  15. data/lib/agent_harness/command_executor.rb +146 -0
  16. data/lib/agent_harness/configuration.rb +299 -0
  17. data/lib/agent_harness/error_taxonomy.rb +128 -0
  18. data/lib/agent_harness/errors.rb +63 -0
  19. data/lib/agent_harness/orchestration/circuit_breaker.rb +169 -0
  20. data/lib/agent_harness/orchestration/conductor.rb +179 -0
  21. data/lib/agent_harness/orchestration/health_monitor.rb +170 -0
  22. data/lib/agent_harness/orchestration/metrics.rb +167 -0
  23. data/lib/agent_harness/orchestration/provider_manager.rb +240 -0
  24. data/lib/agent_harness/orchestration/rate_limiter.rb +113 -0
  25. data/lib/agent_harness/providers/adapter.rb +163 -0
  26. data/lib/agent_harness/providers/aider.rb +109 -0
  27. data/lib/agent_harness/providers/anthropic.rb +345 -0
  28. data/lib/agent_harness/providers/base.rb +198 -0
  29. data/lib/agent_harness/providers/codex.rb +100 -0
  30. data/lib/agent_harness/providers/cursor.rb +281 -0
  31. data/lib/agent_harness/providers/gemini.rb +136 -0
  32. data/lib/agent_harness/providers/github_copilot.rb +155 -0
  33. data/lib/agent_harness/providers/kilocode.rb +73 -0
  34. data/lib/agent_harness/providers/opencode.rb +75 -0
  35. data/lib/agent_harness/providers/registry.rb +137 -0
  36. data/lib/agent_harness/response.rb +100 -0
  37. data/lib/agent_harness/token_tracker.rb +170 -0
  38. data/lib/agent_harness/version.rb +5 -0
  39. data/lib/agent_harness.rb +115 -0
  40. data/release-please-config.json +63 -0
  41. metadata +129 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 239859e53d6ded09a6a30e02a5ebf4ac5092482d794f7ce494df2c1d711d82fe
4
+ data.tar.gz: 54db13eb5a096f208c65baa77072ceea9ca5305d71dd01afafc1d4d590d8c6c6
5
+ SHA512:
6
+ metadata.gz: e50d3e157e97b178747fe89c9cadf8f9993ce317c639161283ec7e16d2dfe2057f486bb8745459feb980745fba90b38a5e96259b63929d7acfdc3da527d7f2a6
7
+ data.tar.gz: 125252e571a43caa8c0bca133fa1564de465637069af8981792942ad2fc1deb9ce6367083dde39e18fba756bf7545dcad9fb911f2664812dd7db24dbc5b1b20e
data/.markdownlint.yml ADDED
@@ -0,0 +1,6 @@
1
+ # Temporary markdownlint configuration to unblock CI and commits.
2
+ # We disable or relax rules that generate large volumes of legacy violations.
3
+ # Follow-up work can selectively re-enable rules and fix content incrementally.
4
+
5
+ MD013: false # Line length disabled
6
+ MD024: false # Allow duplicate headings (handled semantically by higher-level sections)
@@ -0,0 +1,8 @@
1
+ # Ignore auto-generated changelog from lint rules except MD013 (line length disabled separately)
2
+ CHANGELOG.md
3
+
4
+ # Ignore temporary test files (matches .gitignore)
5
+ tmp/
6
+
7
+ # devcontainer paths
8
+ vendor/bundle
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.2.1"
3
+ }
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.simplecov ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Central SimpleCov configuration.
4
+ # Run with: COVERAGE=1 bundle exec rspec
5
+
6
+ require "simplecov"
7
+
8
+ SimpleCov.start do
9
+ enable_coverage :branch
10
+
11
+ track_files "lib/**/*.rb"
12
+
13
+ add_filter "lib/agent_harness/version.rb"
14
+ add_filter "/spec/"
15
+ add_filter "/pkg/"
16
+ add_filter "/tmp/"
17
+
18
+ add_group "Core", "lib/agent_harness"
19
+ add_group "Providers", "lib/agent_harness/providers"
20
+ add_group "Orchestration", "lib/agent_harness/orchestration"
21
+ add_group "Configuration", "lib/agent_harness/configuration"
22
+
23
+ # Match AIDP coverage thresholds
24
+ minimum_coverage line: 82, branch: 64
25
+ minimum_coverage_by_file 58
26
+ end
data/.tool-versions ADDED
@@ -0,0 +1 @@
1
+ ruby 3.4.8
data/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.2.1](https://github.com/viamin/agent-harness/compare/agent-harness/v0.2.0...agent-harness/v0.2.1) (2026-01-26)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * add workflow to update release-please lockfile automatically ([0c01f92](https://github.com/viamin/agent-harness/commit/0c01f92b26eb7f1aa12c5a71776b889756240f1f))
9
+ * remove extra-files configuration from release-please config ([3fa12df](https://github.com/viamin/agent-harness/commit/3fa12df710b6b6e6e0ec8f1f8da2de86bc3e8503))
10
+ * update release-please config to include Gemfile.lock as extra file ([be91fff](https://github.com/viamin/agent-harness/commit/be91fff227cc56c0fd9850ac54a2bec09755f8d6))
11
+
12
+ ## [0.2.0](https://github.com/viamin/agent-harness/compare/agent-harness-v0.1.0...agent-harness/v0.2.0) (2026-01-26)
13
+
14
+
15
+ ### Features
16
+
17
+ * initial extraction of agent-harness code from aidp ([a87e4ec](https://github.com/viamin/agent-harness/commit/a87e4ecfd50415bb2f4dacb5946cdd84160617bf))
18
+ * initial extraction of agent-harness code from aidp ([8563466](https://github.com/viamin/agent-harness/commit/856346661e06962d5b2c05710a4928f484526931))
19
+
20
+
21
+ ### Improvements
22
+
23
+ * streamline command execution and error handling in various modules ([7f0e50c](https://github.com/viamin/agent-harness/commit/7f0e50c9dc00e8b2a81af8738f46fb894b629a2f))
24
+
25
+ ## [0.1.0] - 2026-01-24
26
+
27
+ - Initial release
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "agent-harness" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["bart@sonic.net"](mailto:"bart@sonic.net").
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Bart Agapinan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,274 @@
1
+ # AgentHarness
2
+
3
+ A unified Ruby interface for CLI-based AI coding agents like Claude Code, Cursor, Gemini CLI, GitHub Copilot, and more.
4
+
5
+ ## Features
6
+
7
+ - **Unified Interface**: Single API for multiple AI coding agents
8
+ - **8 Built-in Providers**: Claude Code, Cursor, Gemini CLI, GitHub Copilot, Codex, Aider, OpenCode, Kilocode
9
+ - **Full Orchestration**: Provider switching, circuit breakers, rate limiting, and health monitoring
10
+ - **Flexible Configuration**: YAML, Ruby DSL, or environment variables
11
+ - **Token Tracking**: Monitor usage across providers for cost and limit management
12
+ - **Error Taxonomy**: Standardized error classification for consistent error handling
13
+ - **Dynamic Registration**: Add custom providers at runtime
14
+
15
+ ## Installation
16
+
17
+ Add to your Gemfile:
18
+
19
+ ```ruby
20
+ gem "agent-harness"
21
+ ```
22
+
23
+ Or install directly:
24
+
25
+ ```bash
26
+ gem install agent-harness
27
+ ```
28
+
29
+ ## Quick Start
30
+
31
+ ```ruby
32
+ require "agent_harness"
33
+
34
+ # Send a message using the default provider
35
+ response = AgentHarness.send_message("Write a hello world function in Ruby")
36
+ puts response.output
37
+
38
+ # Use a specific provider
39
+ response = AgentHarness.send_message("Explain this code", provider: :cursor)
40
+ ```
41
+
42
+ ## Configuration
43
+
44
+ ### Ruby DSL
45
+
46
+ ```ruby
47
+ AgentHarness.configure do |config|
48
+ # Logging
49
+ config.logger = Logger.new(STDOUT)
50
+ config.log_level = :info
51
+
52
+ # Default provider
53
+ config.default_provider = :claude
54
+ config.fallback_providers = [:cursor, :gemini]
55
+
56
+ # Timeouts
57
+ config.default_timeout = 300
58
+
59
+ # Orchestration
60
+ config.orchestration do |orch|
61
+ orch.enabled = true
62
+ orch.auto_switch_on_error = true
63
+ orch.auto_switch_on_rate_limit = true
64
+
65
+ orch.circuit_breaker do |cb|
66
+ cb.enabled = true
67
+ cb.failure_threshold = 5
68
+ cb.timeout = 300
69
+ end
70
+
71
+ orch.retry do |r|
72
+ r.enabled = true
73
+ r.max_attempts = 3
74
+ r.base_delay = 1.0
75
+ end
76
+ end
77
+
78
+ # Provider-specific configuration
79
+ config.provider(:claude) do |p|
80
+ p.enabled = true
81
+ p.timeout = 600
82
+ p.model = "claude-sonnet-4-20250514"
83
+ end
84
+
85
+ # Callbacks
86
+ config.on_tokens_used do |event|
87
+ puts "Used #{event.total_tokens} tokens on #{event.provider}"
88
+ end
89
+
90
+ config.on_provider_switch do |event|
91
+ puts "Switched from #{event[:from]} to #{event[:to]}: #{event[:reason]}"
92
+ end
93
+ end
94
+ ```
95
+
96
+ ## Providers
97
+
98
+ ### Built-in Providers
99
+
100
+ | Provider | CLI Binary | Description |
101
+ | -------- | ---------- | ----------- |
102
+ | `:claude` | `claude` | Anthropic Claude Code CLI |
103
+ | `:cursor` | `cursor-agent` | Cursor AI editor CLI |
104
+ | `:gemini` | `gemini` | Google Gemini CLI |
105
+ | `:github_copilot` | `copilot` | GitHub Copilot CLI |
106
+ | `:codex` | `codex` | OpenAI Codex CLI |
107
+ | `:aider` | `aider` | Aider coding assistant |
108
+ | `:opencode` | `opencode` | OpenCode CLI |
109
+ | `:kilocode` | `kilocode` | Kilocode CLI |
110
+
111
+ ### Direct Provider Access
112
+
113
+ ```ruby
114
+ # Get a provider instance
115
+ provider = AgentHarness.provider(:claude)
116
+ response = provider.send_message(prompt: "Hello!")
117
+
118
+ # Check provider availability
119
+ if AgentHarness::Providers::Registry.instance.get(:claude).available?
120
+ puts "Claude CLI is installed"
121
+ end
122
+
123
+ # List all registered providers
124
+ AgentHarness::Providers::Registry.instance.all
125
+ # => [:claude, :cursor, :gemini, :github_copilot, :codex, :opencode, :kilocode, :aider]
126
+ ```
127
+
128
+ ### Custom Providers
129
+
130
+ ```ruby
131
+ class MyProvider < AgentHarness::Providers::Base
132
+ class << self
133
+ def provider_name
134
+ :my_provider
135
+ end
136
+
137
+ def binary_name
138
+ "my-cli"
139
+ end
140
+
141
+ def available?
142
+ system("which my-cli > /dev/null 2>&1")
143
+ end
144
+ end
145
+
146
+ protected
147
+
148
+ def build_command(prompt, options)
149
+ [self.class.binary_name, "--prompt", prompt]
150
+ end
151
+
152
+ def parse_response(result, duration:)
153
+ AgentHarness::Response.new(
154
+ output: result.stdout,
155
+ exit_code: result.exit_code,
156
+ provider: self.class.provider_name,
157
+ duration: duration
158
+ )
159
+ end
160
+ end
161
+
162
+ # Register the custom provider
163
+ AgentHarness::Providers::Registry.instance.register(:my_provider, MyProvider)
164
+ ```
165
+
166
+ ## Orchestration
167
+
168
+ ### Circuit Breaker
169
+
170
+ Prevents cascading failures by stopping requests to unhealthy providers:
171
+
172
+ ```ruby
173
+ # After 5 consecutive failures, the circuit opens for 5 minutes
174
+ config.orchestration.circuit_breaker.failure_threshold = 5
175
+ config.orchestration.circuit_breaker.timeout = 300
176
+ ```
177
+
178
+ ### Rate Limiting
179
+
180
+ Track and respect provider rate limits:
181
+
182
+ ```ruby
183
+ manager = AgentHarness.conductor.provider_manager
184
+
185
+ # Mark a provider as rate limited
186
+ manager.mark_rate_limited(:claude, reset_at: Time.now + 3600)
187
+
188
+ # Check rate limit status
189
+ manager.rate_limited?(:claude)
190
+ ```
191
+
192
+ ### Health Monitoring
193
+
194
+ Monitor provider health and automatically switch on failures:
195
+
196
+ ```ruby
197
+ manager = AgentHarness.conductor.provider_manager
198
+
199
+ # Record success/failure
200
+ manager.record_success(:claude)
201
+ manager.record_failure(:claude)
202
+
203
+ # Check health
204
+ manager.healthy?(:claude)
205
+
206
+ # Get available providers
207
+ manager.available_providers
208
+ ```
209
+
210
+ ### Token Tracking
211
+
212
+ ```ruby
213
+ # Track tokens across requests
214
+ AgentHarness.token_tracker.on_tokens_used do |event|
215
+ puts "Provider: #{event.provider}"
216
+ puts "Input tokens: #{event.input_tokens}"
217
+ puts "Output tokens: #{event.output_tokens}"
218
+ puts "Total: #{event.total_tokens}"
219
+ end
220
+
221
+ # Get usage summary
222
+ AgentHarness.token_tracker.summary
223
+ ```
224
+
225
+ ## Error Handling
226
+
227
+ ```ruby
228
+ begin
229
+ response = AgentHarness.send_message("Hello")
230
+ rescue AgentHarness::TimeoutError => e
231
+ puts "Request timed out"
232
+ rescue AgentHarness::RateLimitError => e
233
+ puts "Rate limited, retry after: #{e.reset_time}"
234
+ rescue AgentHarness::NoProvidersAvailableError => e
235
+ puts "All providers unavailable: #{e.attempted_providers}"
236
+ rescue AgentHarness::Error => e
237
+ puts "Provider error: #{e.message}"
238
+ end
239
+ ```
240
+
241
+ ### Error Taxonomy
242
+
243
+ Classify errors for consistent handling:
244
+
245
+ ```ruby
246
+ category = AgentHarness::ErrorTaxonomy.classify_message("rate limit exceeded")
247
+ # => :rate_limited
248
+
249
+ AgentHarness::ErrorTaxonomy.retryable?(category)
250
+ # => false (rate limits should switch provider, not retry)
251
+
252
+ AgentHarness::ErrorTaxonomy.action_for(category)
253
+ # => :switch_provider
254
+ ```
255
+
256
+ ## Development
257
+
258
+ ```bash
259
+ # Install dependencies
260
+ bin/setup
261
+
262
+ # Run tests
263
+ bundle exec rake spec
264
+
265
+ # Run linter
266
+ bundle exec standardrb
267
+
268
+ # Interactive console
269
+ bin/console
270
+ ```
271
+
272
+ ## License
273
+
274
+ MIT License. See [LICENSE.txt](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "fileutils"
6
+
7
+ # Shared constants
8
+ COVERAGE_DIR = File.expand_path("coverage", __dir__)
9
+
10
+ RSpec::Core::RakeTask.new(:spec)
11
+
12
+ require "standard/rake"
13
+
14
+ task default: %i[spec standard]
15
+
16
+ # Coverage tasks
17
+ namespace :coverage do
18
+ desc "Run RSpec with coverage (COVERAGE=1)"
19
+ task :run do
20
+ ENV["COVERAGE"] = "1"
21
+ Rake::Task["spec"].reenable
22
+ Rake::Task["spec"].invoke
23
+ puts "\nCoverage report: #{File.join(COVERAGE_DIR, "index.html")}" if File.exist?(File.join(COVERAGE_DIR,
24
+ "index.html"))
25
+ end
26
+
27
+ desc "Clean coverage artifacts"
28
+ task :clean do
29
+ if Dir.exist?(COVERAGE_DIR)
30
+ rm_r COVERAGE_DIR
31
+ puts "Removed #{COVERAGE_DIR}"
32
+ else
33
+ puts "No coverage directory to remove"
34
+ end
35
+ end
36
+
37
+ desc "Clean then run coverage"
38
+ task all: %i[clean run]
39
+
40
+ desc "Write coverage summary.json & badge.svg (requires prior coverage:run)"
41
+ task :summary do
42
+ require "json"
43
+ resultset = File.join(COVERAGE_DIR, ".resultset.json")
44
+ unless File.exist?(resultset)
45
+ puts "No coverage data found. Run 'rake coverage:run' first."
46
+ next
47
+ end
48
+ data = JSON.parse(File.read(resultset))
49
+ coverage_hash = data["RSpec"]["coverage"] if data["RSpec"]
50
+ unless coverage_hash
51
+ puts "Unexpected resultset structure, cannot find rspec.coverage"
52
+ next
53
+ end
54
+ covered = 0
55
+ total = 0
56
+ coverage_hash.each_value do |file_cov|
57
+ lines = file_cov["lines"]
58
+ lines.each do |val|
59
+ next if val.nil?
60
+
61
+ total += 1
62
+ covered += 1 if val > 0
63
+ end
64
+ end
65
+ pct = total.positive? ? (covered.to_f / total * 100.0) : 0.0
66
+ summary_file = File.join(COVERAGE_DIR, "summary.json")
67
+ File.write(summary_file, JSON.pretty_generate({timestamp: Time.now.utc.iso8601, line_coverage: pct.round(2)}))
68
+ # Badge
69
+ color = case pct
70
+ when 90..100 then "#4c1"
71
+ when 80...90 then "#97CA00"
72
+ when 70...80 then "#dfb317"
73
+ when 60...70 then "#fe7d37"
74
+ else "#e05d44"
75
+ end
76
+ badge = <<~SVG
77
+ <svg xmlns="http://www.w3.org/2000/svg" width="150" height="20" role="img" aria-label="coverage: #{pct.round(2)}%">
78
+ <linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
79
+ <rect rx="3" width="150" height="20" fill="#555"/>
80
+ <rect rx="3" x="70" width="80" height="20" fill="#{color}"/>
81
+ <path fill="#{color}" d="M70 0h4v20h-4z"/>
82
+ <rect rx="3" width="150" height="20" fill="url(#s)"/>
83
+ <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
84
+ <text x="35" y="14">coverage</text>
85
+ <text x="110" y="14">#{format("%.2f", pct)}%</text>
86
+ </g>
87
+ </svg>
88
+ SVG
89
+ # Write standard coverage/badge.svg and duplicate to badges/coverage.svg for README stability
90
+ File.write(File.join(COVERAGE_DIR, "badge.svg"), badge)
91
+ badges_dir = File.join("badges")
92
+ FileUtils.mkdir_p(badges_dir)
93
+ File.write(File.join(badges_dir, "coverage.svg"), badge)
94
+ puts "Coverage: #{pct.round(2)}% (summary.json, coverage/badge.svg & badges/coverage.svg written)"
95
+ end
96
+ end
97
+
98
+ # Pre-commit preparation task
99
+ desc "Run standard:fix and coverage:run (pre-commit helper)"
100
+ task prep: ["standard:fix", "coverage:run"]
101
+
102
+ desc "Alias for prep"
103
+ task pc: :prep
data/bin/console ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "agent/harness"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ require "irb"
11
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "timeout"
5
+ require "shellwords"
6
+
7
+ module AgentHarness
8
+ # Executes shell commands with timeout support
9
+ #
10
+ # Provides a clean interface for running CLI commands with proper
11
+ # error handling, timeout support, and result capture.
12
+ #
13
+ # @example Basic usage
14
+ # executor = AgentHarness::CommandExecutor.new
15
+ # result = executor.execute(["claude", "--print", "--prompt", "Hello"])
16
+ # puts result.stdout
17
+ #
18
+ # @example With timeout
19
+ # result = executor.execute("claude --print", timeout: 300)
20
+ class CommandExecutor
21
+ # Result of a command execution
22
+ Result = Struct.new(:stdout, :stderr, :exit_code, :duration, keyword_init: true) do
23
+ def success?
24
+ exit_code == 0
25
+ end
26
+
27
+ def failed?
28
+ !success?
29
+ end
30
+ end
31
+
32
+ attr_reader :logger
33
+
34
+ def initialize(logger: nil)
35
+ @logger = logger
36
+ end
37
+
38
+ # Execute a command with optional timeout
39
+ #
40
+ # @param command [Array<String>, String] command to execute
41
+ # @param timeout [Integer, nil] timeout in seconds
42
+ # @param env [Hash] environment variables
43
+ # @param stdin_data [String, nil] data to send to stdin
44
+ # @return [Result] execution result
45
+ # @raise [TimeoutError] if the command times out
46
+ def execute(command, timeout: nil, env: {}, stdin_data: nil)
47
+ cmd_array = normalize_command(command)
48
+ cmd_string = cmd_array.shelljoin
49
+
50
+ log_debug("Executing command", command: cmd_string, timeout: timeout)
51
+
52
+ start_time = Time.now
53
+
54
+ stdout, stderr, status = if timeout
55
+ execute_with_timeout(cmd_array, timeout: timeout, env: env, stdin_data: stdin_data)
56
+ else
57
+ execute_without_timeout(cmd_array, env: env, stdin_data: stdin_data)
58
+ end
59
+
60
+ duration = Time.now - start_time
61
+
62
+ Result.new(
63
+ stdout: stdout,
64
+ stderr: stderr,
65
+ exit_code: status.exitstatus,
66
+ duration: duration
67
+ )
68
+ end
69
+
70
+ # Check if a binary exists in PATH
71
+ #
72
+ # @param binary [String] binary name
73
+ # @return [String, nil] full path or nil
74
+ def which(binary)
75
+ ENV["PATH"].split(File::PATH_SEPARATOR).each do |path|
76
+ full_path = File.join(path, binary)
77
+ return full_path if File.executable?(full_path)
78
+ end
79
+ nil
80
+ end
81
+
82
+ # Check if a binary is available
83
+ #
84
+ # @param binary [String] binary name
85
+ # @return [Boolean] true if available
86
+ def available?(binary)
87
+ !which(binary).nil?
88
+ end
89
+
90
+ private
91
+
92
+ def normalize_command(command)
93
+ case command
94
+ when Array
95
+ command.map(&:to_s)
96
+ when String
97
+ Shellwords.split(command)
98
+ else
99
+ raise ArgumentError, "Command must be Array or String"
100
+ end
101
+ end
102
+
103
+ def execute_with_timeout(cmd_array, timeout:, env:, stdin_data:)
104
+ stdout = ""
105
+ stderr = ""
106
+ status = nil
107
+
108
+ Timeout.timeout(timeout) do
109
+ Open3.popen3(env, *cmd_array) do |stdin, stdout_io, stderr_io, wait_thr|
110
+ if stdin_data
111
+ stdin.write(stdin_data)
112
+ end
113
+ stdin.close
114
+
115
+ # Read output streams
116
+ stdout = stdout_io.read
117
+ stderr = stderr_io.read
118
+ status = wait_thr.value
119
+ end
120
+ end
121
+
122
+ [stdout, stderr, status]
123
+ rescue Timeout::Error
124
+ raise TimeoutError, "Command timed out after #{timeout} seconds: #{cmd_array.first}"
125
+ end
126
+
127
+ def execute_without_timeout(cmd_array, env:, stdin_data:)
128
+ Open3.popen3(env, *cmd_array) do |stdin, stdout_io, stderr_io, wait_thr|
129
+ if stdin_data
130
+ stdin.write(stdin_data)
131
+ end
132
+ stdin.close
133
+
134
+ stdout = stdout_io.read
135
+ stderr = stderr_io.read
136
+ status = wait_thr.value
137
+
138
+ [stdout, stderr, status]
139
+ end
140
+ end
141
+
142
+ def log_debug(message, **context)
143
+ @logger&.debug("[AgentHarness::CommandExecutor] #{message}: #{context.inspect}")
144
+ end
145
+ end
146
+ end