agent-cli-runtime 0.1.0 → 0.2.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.
@@ -1,4 +1,11 @@
1
1
  module AgentCliRuntime
2
+ OPENCODE_OVERLAY_ENVIRONMENT_KEYS = %w[
3
+ XDG_CONFIG_HOME XDG_DATA_HOME XDG_CACHE_HOME XDG_STATE_HOME TMPDIR
4
+ OPENCODE_CONFIG OPENCODE_DISABLE_PROJECT_CONFIG
5
+ OPENCODE_DISABLE_CLAUDE_CODE OPENCODE_DISABLE_MODELS_FETCH
6
+ OPENCODE_DISABLE_AUTOUPDATE OPENCODE_PURE
7
+ ].freeze
8
+
2
9
  module Immutable
3
10
  module_function
4
11
 
@@ -14,6 +21,28 @@ module AgentCliRuntime
14
21
  def symbols(values)
15
22
  Array(values).map(&:to_sym).uniq.freeze
16
23
  end
24
+
25
+ def hash(value)
26
+ unless value.is_a?(Hash)
27
+ raise ArgumentError, "value must be a Hash"
28
+ end
29
+
30
+ value.each_with_object({}) do |(key, item), result|
31
+ result[string(key)] = deep(item)
32
+ end.freeze
33
+ end
34
+
35
+ def deep(value)
36
+ case value
37
+ when Hash then hash(value)
38
+ when Array then value.map { |item| deep(item) }.freeze
39
+ when String then string(value)
40
+ when Symbol, Numeric, TrueClass, FalseClass, NilClass then value
41
+ else
42
+ raise ArgumentError,
43
+ "unsupported immutable value #{value.class}"
44
+ end
45
+ end
17
46
  end
18
47
  private_constant :Immutable
19
48
 
@@ -154,10 +183,11 @@ module AgentCliRuntime
154
183
 
155
184
  ObservableResult = Data.define(
156
185
  :provider, :launcher_identity, :exit_code, :timed_out, :status,
157
- :usage, :final_message, :diagnostic
186
+ :usage, :final_message, :diagnostic, :provider_signal
158
187
  ) do
159
188
  def initialize(provider:, launcher_identity:, exit_code:, timed_out:,
160
- status:, usage:, final_message:, diagnostic:)
189
+ status:, usage:, final_message:, diagnostic:,
190
+ provider_signal: nil)
161
191
  super(
162
192
  provider: provider.to_sym,
163
193
  launcher_identity: Immutable.string(launcher_identity),
@@ -167,8 +197,412 @@ module AgentCliRuntime
167
197
  usage: usage&.dup&.freeze,
168
198
  final_message:
169
199
  final_message.nil? ? nil : Immutable.string(final_message),
170
- diagnostic: diagnostic.nil? ? nil : Immutable.string(diagnostic)
200
+ diagnostic: diagnostic.nil? ? nil : Immutable.string(diagnostic),
201
+ provider_signal: provider_signal&.dup&.freeze
202
+ )
203
+ end
204
+ end
205
+
206
+
207
+ Route = Data.define(:provider, :model) do
208
+ ROUTE_PATTERN = /\A(?<provider>[a-zA-Z0-9][a-zA-Z0-9._-]*)\/(?<model>[^\s\/][^\s]*)\z/
209
+
210
+ def initialize(provider:, model:)
211
+ normalized_provider = Immutable.string(provider)
212
+ normalized_model = Immutable.string(model)
213
+ route = "#{normalized_provider}/#{normalized_model}"
214
+ unless ROUTE_PATTERN.match?(route) && !normalized_model.include?("\0")
215
+ raise ArgumentError,
216
+ "OpenCode route must be a full provider/model value"
217
+ end
218
+
219
+ super(provider: normalized_provider, model: normalized_model)
220
+ end
221
+
222
+ def self.parse(value)
223
+ match = ROUTE_PATTERN.match(value.to_s)
224
+ unless match
225
+ raise ArgumentError,
226
+ "OpenCode route must be a full provider/model value"
227
+ end
228
+
229
+ new(provider: match[:provider], model: match[:model])
230
+ end
231
+
232
+ def to_s
233
+ "#{provider}/#{model}"
234
+ end
235
+ end
236
+
237
+ OpenCodePermissionPolicy = Data.define(:rules) do
238
+ ACTIONS = %w[allow ask deny].freeze
239
+
240
+ def initialize(rules = nil, **keywords)
241
+ value = rules || keywords[:rules] || keywords
242
+ normalized = Immutable.hash(value || {})
243
+ raise ArgumentError, "OpenCode permission policy cannot be empty" if normalized.empty?
244
+
245
+ validate_actions!(normalized)
246
+ super(rules: normalized)
247
+ end
248
+
249
+ private
250
+
251
+ def validate_actions!(value)
252
+ value.each_value do |entry|
253
+ if entry.is_a?(Hash)
254
+ validate_actions!(entry)
255
+ elsif !ACTIONS.include?(entry)
256
+ raise ArgumentError,
257
+ "OpenCode permission action must be allow, ask, or deny"
258
+ end
259
+ end
260
+ end
261
+ end
262
+
263
+ OpenCodePreparationRequest = Data.define(
264
+ :request, :working_directory, :invocation_root,
265
+ :configuration_path, :configuration,
266
+ :credential_environment_keys, :credential_file,
267
+ :permission_policy, :additional_read_roots,
268
+ :additional_write_roots, :edit_patterns, :plugins, :pure
269
+ ) do
270
+ def initialize(request:, working_directory:, invocation_root:,
271
+ configuration_path: nil, configuration: nil,
272
+ credential_environment_keys: [], credential_file: nil,
273
+ permission_policy: nil, additional_read_roots: [],
274
+ additional_write_roots: [], edit_patterns: [], plugins: [], pure: true)
275
+ unless request.is_a?(Request)
276
+ raise ArgumentError, "request must be an AgentCliRuntime::Request"
277
+ end
278
+ if configuration_path && configuration
279
+ raise ArgumentError,
280
+ "choose configuration_path or configuration, not both"
281
+ end
282
+ if permission_policy &&
283
+ !permission_policy.is_a?(OpenCodePermissionPolicy)
284
+ raise ArgumentError,
285
+ "permission_policy must be an OpenCodePermissionPolicy"
286
+ end
287
+
288
+ keys = Immutable.strings(credential_environment_keys)
289
+ invalid = keys.find { |key| !key.match?(/\A[A-Z][A-Z0-9_]*\z/) }
290
+ raise ArgumentError, "invalid credential environment key" if invalid
291
+ raise ArgumentError, "credential environment keys must be unique" if keys.uniq.length != keys.length
292
+ reserved = keys & OPENCODE_OVERLAY_ENVIRONMENT_KEYS
293
+ unless reserved.empty?
294
+ raise ArgumentError,
295
+ "credential environment keys cannot override the OpenCode overlay: #{reserved.join(', ')}"
296
+ end
297
+
298
+ super(
299
+ request: request,
300
+ working_directory: Immutable.string(working_directory),
301
+ invocation_root: Immutable.string(invocation_root),
302
+ configuration_path:
303
+ configuration_path.nil? ? nil : Immutable.string(configuration_path),
304
+ configuration:
305
+ configuration.nil? ? nil : Immutable.hash(configuration),
306
+ credential_environment_keys: keys,
307
+ credential_file:
308
+ credential_file.nil? ? nil : Immutable.string(credential_file),
309
+ permission_policy: permission_policy,
310
+ additional_read_roots: Immutable.strings(additional_read_roots),
311
+ additional_write_roots: Immutable.strings(additional_write_roots),
312
+ edit_patterns: Immutable.strings(edit_patterns),
313
+ plugins: Immutable.strings(plugins),
314
+ pure: pure != false
315
+ )
316
+ end
317
+ end
318
+
319
+ ProbeRequest = Data.define(
320
+ :profile, :route, :variant, :environment,
321
+ :credential_environment_keys, :credential_file_staged
322
+ ) do
323
+ def initialize(profile:, route:, variant: nil, environment: {},
324
+ credential_environment_keys: [],
325
+ credential_file_staged: false)
326
+ parsed_route = route.is_a?(Route) ? route : Route.parse(route)
327
+ super(
328
+ profile: profile,
329
+ route: parsed_route,
330
+ variant: variant.nil? ? nil : Immutable.string(variant),
331
+ environment: Immutable.hash(environment),
332
+ credential_environment_keys:
333
+ Immutable.strings(credential_environment_keys),
334
+ credential_file_staged: credential_file_staged == true
335
+ )
336
+ end
337
+ end
338
+
339
+ RouteProbeResult = Data.define(
340
+ :provider, :ready, :installed, :executable, :version,
341
+ :minimum_version, :auth_configuration, :route,
342
+ :route_available, :available_variants,
343
+ :capability_evidence, :diagnostic
344
+ ) do
345
+ def initialize(provider:, ready:, installed:, executable:, version:,
346
+ minimum_version:, auth_configuration:, route:,
347
+ route_available:, available_variants:,
348
+ capability_evidence:, diagnostic: nil)
349
+ super(
350
+ provider: provider.to_sym,
351
+ ready: ready == true,
352
+ installed: installed == true,
353
+ executable: Immutable.string(executable),
354
+ version: version.nil? ? nil : Immutable.string(version),
355
+ minimum_version:
356
+ minimum_version.nil? ? nil : Immutable.string(minimum_version),
357
+ auth_configuration: auth_configuration,
358
+ route: route,
359
+ route_available: route_available == true,
360
+ available_variants: Immutable.strings(available_variants),
361
+ capability_evidence: Array(capability_evidence).freeze,
362
+ diagnostic:
363
+ diagnostic.nil? ? nil : Immutable.string(diagnostic)
364
+ )
365
+ end
366
+ end
367
+
368
+ class PreparedInvocation
369
+ attr_reader :invocation, :environment, :credential_environment_keys,
370
+ :invocation_root, :generated_paths, :configuration_path,
371
+ :requested_route, :configuration_source, :probe_result,
372
+ :executable
373
+
374
+ def initialize(invocation:, environment:, credential_environment_keys:,
375
+ invocation_root:, generated_paths:, configuration_path:,
376
+ requested_route:, configuration_source:, probe_result:,
377
+ cleanup:, executable: nil)
378
+ @invocation = invocation
379
+ @environment = Immutable.hash(environment)
380
+ @credential_environment_keys =
381
+ Immutable.strings(credential_environment_keys)
382
+ @invocation_root = Immutable.string(invocation_root)
383
+ @generated_paths = Immutable.strings(generated_paths)
384
+ @configuration_path = Immutable.string(configuration_path)
385
+ @requested_route = requested_route
386
+ @configuration_source =
387
+ configuration_source.nil? ? nil : Immutable.string(configuration_source)
388
+ @probe_result = probe_result
389
+ @executable = Immutable.string(executable || invocation.argv.fetch(0))
390
+ @cleanup = cleanup
391
+ freeze
392
+ end
393
+
394
+ def environment_for(env: ENV)
395
+ selected = credential_environment_keys.each_with_object({}) do |key, values|
396
+ value = env[key]
397
+ values[key] = value.to_s unless value.to_s.empty?
398
+ end
399
+ selected.merge(environment).freeze
400
+ end
401
+
402
+ def cleanup!
403
+ @cleanup.call
404
+ nil
405
+ end
406
+ end
407
+
408
+ TerminationEvidence = Data.define(
409
+ :exit_code, :timed_out, :cancelled, :signal
410
+ ) do
411
+ def initialize(exit_code:, timed_out: false, cancelled: false, signal: nil)
412
+ unless exit_code.nil? || exit_code.is_a?(Integer)
413
+ raise ArgumentError, "exit_code must be an Integer or nil"
414
+ end
415
+
416
+ super(
417
+ exit_code: exit_code,
418
+ timed_out: timed_out == true,
419
+ cancelled: cancelled == true,
420
+ signal: signal.nil? ? nil : Immutable.string(signal)
421
+ )
422
+ end
423
+
424
+ def success?
425
+ !timed_out && !cancelled && signal.nil? && exit_code == 0
426
+ end
427
+ end
428
+
429
+ CapturedResult = Data.define(
430
+ :stdout, :stderr, :termination, :inspection_output
431
+ ) do
432
+ def initialize(stdout:, stderr:, termination:, inspection_output: nil)
433
+ unless termination.is_a?(TerminationEvidence)
434
+ raise ArgumentError, "termination must be TerminationEvidence"
435
+ end
436
+
437
+ super(
438
+ stdout: Immutable.string(stdout),
439
+ stderr: Immutable.string(stderr),
440
+ termination: termination,
441
+ inspection_output:
442
+ inspection_output.nil? ? nil : Immutable.string(inspection_output)
443
+ )
444
+ end
445
+ end
446
+
447
+ NormalizedUsage = Data.define(
448
+ :input, :output, :cache_read, :cache_write, :reasoning, :cost
449
+ ) do
450
+ def initialize(input: nil, output: nil, cache_read: nil, cache_write: nil,
451
+ reasoning: nil, cost: nil)
452
+ super(
453
+ input: number(input, :input, integer: true),
454
+ output: number(output, :output, integer: true),
455
+ cache_read: number(cache_read, :cache_read, integer: true),
456
+ cache_write: number(cache_write, :cache_write, integer: true),
457
+ reasoning: number(reasoning, :reasoning, integer: true),
458
+ cost: number(cost, :cost, integer: false)
459
+ )
460
+ end
461
+
462
+ def cached
463
+ return nil if cache_read.nil? || cache_write.nil?
464
+
465
+ cache_read + cache_write
466
+ end
467
+
468
+ private
469
+
470
+ def number(value, label, integer:)
471
+ return nil if value.nil?
472
+ valid = integer ? value.is_a?(Integer) : value.is_a?(Numeric)
473
+ valid &&= value.finite? if value.respond_to?(:finite?)
474
+ unless valid && value >= 0
475
+ raise ArgumentError, "#{label} must be a non-negative number or nil"
476
+ end
477
+
478
+ value
479
+ end
480
+ end
481
+
482
+ RouteIdentity = Data.define(:requested, :actual, :resolution_status) do
483
+ RESOLUTION_STATUSES = %i[unobserved matched resolved_differently].freeze
484
+
485
+ def initialize(requested:, actual: nil, resolution_status: nil)
486
+ requested_route = requested.is_a?(Route) ? requested : Route.parse(requested)
487
+ actual_route =
488
+ if actual.nil?
489
+ nil
490
+ elsif actual.is_a?(Route)
491
+ actual
492
+ else
493
+ Route.parse(actual)
494
+ end
495
+ status = resolution_status&.to_sym ||
496
+ (actual_route.nil? ? :unobserved :
497
+ (requested_route == actual_route ? :matched : :resolved_differently))
498
+ unless RESOLUTION_STATUSES.include?(status)
499
+ raise ArgumentError, "invalid route resolution status"
500
+ end
501
+
502
+ super(
503
+ requested: requested_route,
504
+ actual: actual_route,
505
+ resolution_status: status
506
+ )
507
+ end
508
+ end
509
+
510
+ ParsedRun = Data.define(
511
+ :session_id, :terminal_message_id, :terminal_reason,
512
+ :final_message, :final_message_truncated, :preliminary_usage, :unknown_events
513
+ ) do
514
+ def initialize(session_id:, terminal_message_id:, terminal_reason:,
515
+ final_message:, preliminary_usage:, unknown_events: [],
516
+ final_message_truncated: false)
517
+ unless preliminary_usage.is_a?(NormalizedUsage)
518
+ raise ArgumentError, "preliminary_usage must be NormalizedUsage"
519
+ end
520
+
521
+ super(
522
+ session_id: Immutable.string(session_id),
523
+ terminal_message_id: Immutable.string(terminal_message_id),
524
+ terminal_reason: Immutable.string(terminal_reason),
525
+ final_message: Immutable.string(final_message),
526
+ final_message_truncated: final_message_truncated == true,
527
+ preliminary_usage: preliminary_usage,
528
+ unknown_events: Immutable.strings(unknown_events)
529
+ )
530
+ end
531
+ end
532
+
533
+ InspectionCommand = Data.define(
534
+ :argv, :stdin_data, :environment, :credential_environment_keys,
535
+ :session_id, :message_id
536
+ ) do
537
+ def initialize(argv:, environment:, credential_environment_keys:,
538
+ session_id:, message_id:, stdin_data: nil)
539
+ super(
540
+ argv: Immutable.strings(argv),
541
+ stdin_data: stdin_data.nil? ? nil : Immutable.string(stdin_data),
542
+ environment: Immutable.hash(environment),
543
+ credential_environment_keys:
544
+ Immutable.strings(credential_environment_keys),
545
+ session_id: Immutable.string(session_id),
546
+ message_id: Immutable.string(message_id)
547
+ )
548
+ end
549
+
550
+ def environment_for(env: ENV)
551
+ selected = credential_environment_keys.each_with_object({}) do |key, values|
552
+ value = env[key]
553
+ values[key] = value.to_s unless value.to_s.empty?
554
+ end
555
+ selected.merge(environment).freeze
556
+ end
557
+ end
558
+
559
+ NormalizedOutcome = Data.define(
560
+ :provider, :launcher_identity, :kind, :termination,
561
+ :final_message, :final_message_truncated, :identity, :usage, :diagnostic,
562
+ :unknown_events, :session_id, :message_id
563
+ ) do
564
+ KINDS = %i[
565
+ completed authentication_failure configuration_failure cli_failure
566
+ malformed_output cancelled timed_out
567
+ ].freeze
568
+
569
+ def initialize(provider:, launcher_identity:, kind:, termination:,
570
+ final_message: nil, identity:, usage: nil, diagnostic: nil,
571
+ unknown_events: [], session_id: nil, message_id: nil,
572
+ final_message_truncated: false)
573
+ normalized_kind = kind.to_sym
574
+ unless KINDS.include?(normalized_kind)
575
+ raise ArgumentError, "invalid normalized outcome kind"
576
+ end
577
+ unless termination.is_a?(TerminationEvidence)
578
+ raise ArgumentError, "termination must be TerminationEvidence"
579
+ end
580
+ unless identity.is_a?(RouteIdentity)
581
+ raise ArgumentError, "identity must be RouteIdentity"
582
+ end
583
+ unless usage.nil? || usage.is_a?(NormalizedUsage)
584
+ raise ArgumentError, "usage must be NormalizedUsage or nil"
585
+ end
586
+
587
+ super(
588
+ provider: provider.to_sym,
589
+ launcher_identity: Immutable.string(launcher_identity),
590
+ kind: normalized_kind,
591
+ termination: termination,
592
+ final_message:
593
+ final_message.nil? ? nil : Immutable.string(final_message),
594
+ final_message_truncated: final_message_truncated == true,
595
+ identity: identity,
596
+ usage: usage,
597
+ diagnostic: diagnostic.nil? ? nil : Immutable.string(diagnostic),
598
+ unknown_events: Immutable.strings(unknown_events),
599
+ session_id: session_id.nil? ? nil : Immutable.string(session_id),
600
+ message_id: message_id.nil? ? nil : Immutable.string(message_id)
171
601
  )
172
602
  end
603
+
604
+ def completed?
605
+ kind == :completed
606
+ end
173
607
  end
174
608
  end
@@ -1,3 +1,3 @@
1
1
  module AgentCliRuntime
2
- VERSION = "0.1.0".freeze
2
+ VERSION = "0.2.0".freeze
3
3
  end
@@ -8,10 +8,14 @@ end
8
8
 
9
9
  require "agent_cli_runtime/redactor"
10
10
  require "agent_cli_runtime/usage_extractors"
11
+ require "agent_cli_runtime/opencode/result_parser"
11
12
  require "agent_cli_runtime/profile"
12
13
  require "agent_cli_runtime/profiles"
13
14
  require "agent_cli_runtime/probe"
14
15
  require "agent_cli_runtime/runtime"
16
+ require "agent_cli_runtime/opencode/probe"
17
+ require "agent_cli_runtime/opencode/overlay"
18
+ require "agent_cli_runtime/opencode/inspection"
15
19
  require "agent_cli_runtime/cli"
16
20
 
17
21
  module AgentCliRuntime
@@ -21,8 +25,8 @@ module AgentCliRuntime
21
25
  Runtime.compile(request)
22
26
  end
23
27
 
24
- def prepare!(profile)
25
- Runtime.prepare!(profile)
28
+ def prepare!(profile, env: ENV)
29
+ Runtime.prepare!(profile, env:)
26
30
  end
27
31
 
28
32
  def require_capability!(profile, capability)
@@ -37,8 +41,24 @@ module AgentCliRuntime
37
41
  Runtime.observe(profile, result)
38
42
  end
39
43
 
44
+ def parse_run(profile, stdout:)
45
+ Runtime.parse_run(profile, stdout:)
46
+ end
47
+
48
+ def prepare_inspection(prepared, parsed_run)
49
+ OpenCode::Inspection.compile(prepared, parsed_run)
50
+ end
51
+
52
+ def normalize(profile, captured, requested_route:)
53
+ Runtime.normalize(profile, captured, requested_route:)
54
+ end
55
+
40
56
  def probe(profile, home: nil, env: ENV)
41
- Probe.call(profile, home: home, env: env)
57
+ if profile.is_a?(ProbeRequest)
58
+ OpenCode::Probe.call(profile, env: env)
59
+ else
60
+ Probe.call(profile, home: home, env: env)
61
+ end
42
62
  end
43
63
 
44
64
  def probe_all(home: nil, env: ENV)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: agent-cli-runtime
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ivan Kuznetsov
@@ -66,8 +66,9 @@ dependencies:
66
66
  description: |
67
67
  Agent CLI Runtime provides immutable profiles, invocation compilation,
68
68
  local prerequisite probes, capability evidence, usage extraction, and
69
- result normalization for Claude Code, Codex CLI, Pi, and Grok CLI. It does
70
- not spawn agents or claim live provider health, quota, or credential validity.
69
+ result normalization for Claude Code, Codex CLI, Pi, Grok CLI, and
70
+ OpenCode. It does not spawn agents or claim live provider health, quota,
71
+ or credential validity.
71
72
  email:
72
73
  - ivan@ikuznetsov.com
73
74
  executables:
@@ -83,6 +84,10 @@ files:
83
84
  - lib/agent_cli_runtime.rb
84
85
  - lib/agent_cli_runtime/cli.rb
85
86
  - lib/agent_cli_runtime/errors.rb
87
+ - lib/agent_cli_runtime/opencode/inspection.rb
88
+ - lib/agent_cli_runtime/opencode/overlay.rb
89
+ - lib/agent_cli_runtime/opencode/probe.rb
90
+ - lib/agent_cli_runtime/opencode/result_parser.rb
86
91
  - lib/agent_cli_runtime/probe.rb
87
92
  - lib/agent_cli_runtime/profile.rb
88
93
  - lib/agent_cli_runtime/profiles.rb
@@ -91,15 +96,15 @@ files:
91
96
  - lib/agent_cli_runtime/usage_extractors.rb
92
97
  - lib/agent_cli_runtime/values.rb
93
98
  - lib/agent_cli_runtime/version.rb
94
- homepage: https://github.com/ivankuznetsov/hive
99
+ homepage: https://github.com/ivankuznetsov/agent-cli-runtime
95
100
  licenses:
96
101
  - MIT
97
102
  metadata:
98
- homepage_uri: https://github.com/ivankuznetsov/hive
99
- source_code_uri: https://github.com/ivankuznetsov/hive/tree/main/components/agent-cli-runtime
100
- changelog_uri: https://github.com/ivankuznetsov/hive/blob/main/components/agent-cli-runtime/CHANGELOG.md
103
+ homepage_uri: https://github.com/ivankuznetsov/agent-cli-runtime
104
+ source_code_uri: https://github.com/ivankuznetsov/agent-cli-runtime
105
+ changelog_uri: https://github.com/ivankuznetsov/agent-cli-runtime/blob/main/CHANGELOG.md
101
106
  bug_tracker_uri: https://github.com/ivankuznetsov/hive/issues
102
- documentation_uri: https://github.com/ivankuznetsov/hive/blob/main/components/agent-cli-runtime/README.md
107
+ documentation_uri: https://github.com/ivankuznetsov/agent-cli-runtime/blob/main/README.md
103
108
  rubygems_mfa_required: 'true'
104
109
  rdoc_options: []
105
110
  require_paths: