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.
- checksums.yaml +7 -0
- data/.markdownlint.yml +6 -0
- data/.markdownlintignore +8 -0
- data/.release-please-manifest.json +3 -0
- data/.rspec +3 -0
- data/.simplecov +26 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +27 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +274 -0
- data/Rakefile +103 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/lib/agent_harness/command_executor.rb +146 -0
- data/lib/agent_harness/configuration.rb +299 -0
- data/lib/agent_harness/error_taxonomy.rb +128 -0
- data/lib/agent_harness/errors.rb +63 -0
- data/lib/agent_harness/orchestration/circuit_breaker.rb +169 -0
- data/lib/agent_harness/orchestration/conductor.rb +179 -0
- data/lib/agent_harness/orchestration/health_monitor.rb +170 -0
- data/lib/agent_harness/orchestration/metrics.rb +167 -0
- data/lib/agent_harness/orchestration/provider_manager.rb +240 -0
- data/lib/agent_harness/orchestration/rate_limiter.rb +113 -0
- data/lib/agent_harness/providers/adapter.rb +163 -0
- data/lib/agent_harness/providers/aider.rb +109 -0
- data/lib/agent_harness/providers/anthropic.rb +345 -0
- data/lib/agent_harness/providers/base.rb +198 -0
- data/lib/agent_harness/providers/codex.rb +100 -0
- data/lib/agent_harness/providers/cursor.rb +281 -0
- data/lib/agent_harness/providers/gemini.rb +136 -0
- data/lib/agent_harness/providers/github_copilot.rb +155 -0
- data/lib/agent_harness/providers/kilocode.rb +73 -0
- data/lib/agent_harness/providers/opencode.rb +75 -0
- data/lib/agent_harness/providers/registry.rb +137 -0
- data/lib/agent_harness/response.rb +100 -0
- data/lib/agent_harness/token_tracker.rb +170 -0
- data/lib/agent_harness/version.rb +5 -0
- data/lib/agent_harness.rb +115 -0
- data/release-please-config.json +63 -0
- 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)
|
data/.markdownlintignore
ADDED
data/.rspec
ADDED
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
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -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,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
|