agent-cli-runtime 0.1.1 → 0.2.4
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 +4 -4
- data/CHANGELOG.md +87 -0
- data/README.md +177 -9
- data/agent-cli-runtime.gemspec +7 -5
- data/lib/agent_cli_runtime/error_extractors.rb +95 -0
- data/lib/agent_cli_runtime/errors.rb +7 -0
- data/lib/agent_cli_runtime/opencode/inspection.rb +32 -0
- data/lib/agent_cli_runtime/opencode/overlay.rb +488 -0
- data/lib/agent_cli_runtime/opencode/permissions.rb +144 -0
- data/lib/agent_cli_runtime/opencode/probe.rb +304 -0
- data/lib/agent_cli_runtime/opencode/result_parser.rb +416 -0
- data/lib/agent_cli_runtime/profile.rb +60 -11
- data/lib/agent_cli_runtime/profiles.rb +98 -3
- data/lib/agent_cli_runtime/runtime.rb +118 -13
- data/lib/agent_cli_runtime/usage_extractors.rb +119 -32
- data/lib/agent_cli_runtime/values.rb +478 -0
- data/lib/agent_cli_runtime/version.rb +1 -1
- data/lib/agent_cli_runtime.rb +29 -3
- metadata +13 -6
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module AgentCliRuntime
|
|
4
|
+
module OpenCode
|
|
5
|
+
module Probe
|
|
6
|
+
REQUIRED_RUN_FLAGS = %w[
|
|
7
|
+
--model --variant --format --dir --pure --auto
|
|
8
|
+
].freeze
|
|
9
|
+
MODEL_INVENTORY_TIMEOUT_SECONDS = 30
|
|
10
|
+
SAFE_ENVIRONMENT_KEYS = %w[
|
|
11
|
+
HOME LANG LC_ALL LOGNAME PATH SHELL SSL_CERT_DIR SSL_CERT_FILE
|
|
12
|
+
TMPDIR USER
|
|
13
|
+
].freeze
|
|
14
|
+
ANSI_PATTERN = /\e\[[0-?]*[ -\/]*[@-~]/
|
|
15
|
+
private_constant :SAFE_ENVIRONMENT_KEYS, :ANSI_PATTERN
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def call(request, env: ENV)
|
|
20
|
+
call!(request, env:)
|
|
21
|
+
rescue Error => e
|
|
22
|
+
profile = Profiles.resolve(request.profile)
|
|
23
|
+
executable = request.executable || profile.bin(env:)
|
|
24
|
+
RouteProbeResult.new(
|
|
25
|
+
provider: profile.name,
|
|
26
|
+
ready: false,
|
|
27
|
+
installed: profile.binary_installed?(env:, executable:),
|
|
28
|
+
executable: executable,
|
|
29
|
+
version: nil,
|
|
30
|
+
minimum_version: profile.min_version,
|
|
31
|
+
auth_configuration: AuthConfiguration.new(status: :not_checked),
|
|
32
|
+
route: request.route,
|
|
33
|
+
route_available: false,
|
|
34
|
+
available_variants: [],
|
|
35
|
+
capability_evidence: [
|
|
36
|
+
CapabilityEvidence.new(
|
|
37
|
+
capability: error_capability(e), supported: false,
|
|
38
|
+
provider: profile.name,
|
|
39
|
+
launcher_identity: profile.launcher_identity,
|
|
40
|
+
diagnostic: Redactor.diagnostic(e)
|
|
41
|
+
)
|
|
42
|
+
],
|
|
43
|
+
diagnostic: Redactor.diagnostic(e)
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def call!(request, env: ENV)
|
|
48
|
+
unless request.is_a?(ProbeRequest)
|
|
49
|
+
raise ArgumentError,
|
|
50
|
+
"request must be an AgentCliRuntime::ProbeRequest"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
profile = Profiles.resolve(request.profile)
|
|
54
|
+
unless profile.name == :opencode
|
|
55
|
+
raise ArgumentError,
|
|
56
|
+
"route-aware ProbeRequest currently requires profile :opencode"
|
|
57
|
+
end
|
|
58
|
+
installed = profile.binary_installed?(env:, executable: request.executable)
|
|
59
|
+
unless installed
|
|
60
|
+
raise BinaryUnavailable,
|
|
61
|
+
"opencode binary not runnable: " \
|
|
62
|
+
"#{request.executable || profile.bin(env:)}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
child_env = child_environment(profile, request, env:)
|
|
66
|
+
version = profile.check_version!(
|
|
67
|
+
env: child_env, executable: request.executable
|
|
68
|
+
)
|
|
69
|
+
evidence = [
|
|
70
|
+
evidence(profile, :installation),
|
|
71
|
+
evidence(profile, :version, [ version ])
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
run_help = capture!(
|
|
75
|
+
profile, child_env, "run", "--help", executable: request.executable
|
|
76
|
+
)
|
|
77
|
+
missing = REQUIRED_RUN_FLAGS.reject { |flag| advertised?(run_help, flag) }
|
|
78
|
+
unless missing.empty?
|
|
79
|
+
raise UnsupportedCapability,
|
|
80
|
+
"OpenCode run is missing required capability #{missing.join(', ')}"
|
|
81
|
+
end
|
|
82
|
+
evidence.concat(REQUIRED_RUN_FLAGS.map do |flag|
|
|
83
|
+
evidence(profile, capability_for(flag), [ flag ])
|
|
84
|
+
end)
|
|
85
|
+
|
|
86
|
+
export_help = capture!(
|
|
87
|
+
profile, child_env, "export", "--help", executable: request.executable
|
|
88
|
+
)
|
|
89
|
+
unless advertised?(export_help, "--sanitize")
|
|
90
|
+
raise UnsupportedCapability,
|
|
91
|
+
"OpenCode export is missing required --sanitize capability"
|
|
92
|
+
end
|
|
93
|
+
evidence << evidence(profile, :sanitized_export, [ "--sanitize" ])
|
|
94
|
+
|
|
95
|
+
auth_output = capture!(
|
|
96
|
+
profile, child_env, "auth", "list", executable: request.executable
|
|
97
|
+
)
|
|
98
|
+
configured_key = request.credential_environment_keys.find do |key|
|
|
99
|
+
!env[key].to_s.empty?
|
|
100
|
+
end
|
|
101
|
+
auth_from_inventory =
|
|
102
|
+
normalized_output(auth_output).match?(
|
|
103
|
+
/(?:\A|[^A-Za-z0-9_.-])#{Regexp.escape(request.route.provider)}(?:\z|[^A-Za-z0-9_.-])/
|
|
104
|
+
)
|
|
105
|
+
unless configured_key || request.credential_file_staged ||
|
|
106
|
+
auth_from_inventory
|
|
107
|
+
raise AuthenticationError,
|
|
108
|
+
"OpenCode authentication source is missing for requested provider"
|
|
109
|
+
end
|
|
110
|
+
auth = AuthConfiguration.new(
|
|
111
|
+
status: :configured,
|
|
112
|
+
source:
|
|
113
|
+
configured_key ? "selected environment" :
|
|
114
|
+
(request.credential_file_staged ? "staged auth file" :
|
|
115
|
+
"local auth inventory")
|
|
116
|
+
)
|
|
117
|
+
evidence << evidence(profile, :auth_configuration)
|
|
118
|
+
|
|
119
|
+
configured_variants = request.configured_variants
|
|
120
|
+
inventory_variants = if configured_variants.nil?
|
|
121
|
+
models_output = capture!(
|
|
122
|
+
profile, child_env, "models", request.route.provider, "--verbose",
|
|
123
|
+
executable: request.executable,
|
|
124
|
+
timeout_sec: MODEL_INVENTORY_TIMEOUT_SECONDS
|
|
125
|
+
)
|
|
126
|
+
variants_for(models_output, request.route.to_s)
|
|
127
|
+
end
|
|
128
|
+
if configured_variants.nil? && inventory_variants.nil?
|
|
129
|
+
raise RouteUnavailable,
|
|
130
|
+
"requested OpenCode route is unavailable in the local model inventory"
|
|
131
|
+
end
|
|
132
|
+
variants = [ *inventory_variants, *configured_variants ].uniq.sort.freeze
|
|
133
|
+
if request.variant && !variants.include?(request.variant)
|
|
134
|
+
raise RouteUnavailable,
|
|
135
|
+
"requested OpenCode variant is unavailable for the exact route"
|
|
136
|
+
end
|
|
137
|
+
evidence << evidence(profile, :model_route, [ request.route.to_s ])
|
|
138
|
+
if inventory_variants.nil?
|
|
139
|
+
evidence << evidence(
|
|
140
|
+
profile, :configured_model_route, [ request.route.to_s ]
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
evidence << evidence(profile, :model_variant, [ request.variant ]) if
|
|
144
|
+
request.variant
|
|
145
|
+
|
|
146
|
+
RouteProbeResult.new(
|
|
147
|
+
provider: profile.name,
|
|
148
|
+
ready: true,
|
|
149
|
+
installed: true,
|
|
150
|
+
executable: request.executable || profile.bin(env: child_env),
|
|
151
|
+
version: version,
|
|
152
|
+
minimum_version: profile.min_version,
|
|
153
|
+
auth_configuration: auth,
|
|
154
|
+
route: request.route,
|
|
155
|
+
route_available: true,
|
|
156
|
+
available_variants: variants,
|
|
157
|
+
capability_evidence: evidence,
|
|
158
|
+
diagnostic: nil
|
|
159
|
+
)
|
|
160
|
+
rescue UnknownProvider
|
|
161
|
+
raise
|
|
162
|
+
rescue Error
|
|
163
|
+
raise
|
|
164
|
+
rescue StandardError => e
|
|
165
|
+
raise ProbeError, Redactor.diagnostic(e)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def child_environment(profile, request, env:)
|
|
169
|
+
selected = (ENV.keys | env.keys).to_h { |key| [ key, nil ] }
|
|
170
|
+
env.each do |key, value|
|
|
171
|
+
if SAFE_ENVIRONMENT_KEYS.include?(key) || key.start_with?("LC_") ||
|
|
172
|
+
key.start_with?("MISE_")
|
|
173
|
+
selected[key] = value.to_s
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
%w[
|
|
177
|
+
BUNDLE_BIN_PATH BUNDLE_GEMFILE GEM_HOME GEM_PATH RUBYLIB RUBYOPT
|
|
178
|
+
].each { |key| selected[key] = nil }
|
|
179
|
+
profile.env_bin_override_keys.each do |key|
|
|
180
|
+
selected[key] = env[key].to_s unless env[key].to_s.empty?
|
|
181
|
+
end
|
|
182
|
+
request.credential_environment_keys.each do |key|
|
|
183
|
+
selected[key] = env[key].to_s unless env[key].to_s.empty?
|
|
184
|
+
end
|
|
185
|
+
selected.merge(request.environment).freeze
|
|
186
|
+
end
|
|
187
|
+
private_class_method :child_environment
|
|
188
|
+
|
|
189
|
+
def capture!(profile, environment, *arguments, executable: nil,
|
|
190
|
+
timeout_sec: nil)
|
|
191
|
+
options = { env: environment, executable: executable }
|
|
192
|
+
options[:timeout_sec] = timeout_sec if timeout_sec
|
|
193
|
+
out, err, status = profile.capture_local(*arguments, **options)
|
|
194
|
+
unless status.success?
|
|
195
|
+
diagnostic = normalized_output("#{err}\n#{out}")
|
|
196
|
+
raise ConfigurationError,
|
|
197
|
+
diagnostic.empty? ?
|
|
198
|
+
"OpenCode local inspection command failed" : diagnostic
|
|
199
|
+
end
|
|
200
|
+
"#{out}\n#{err}"
|
|
201
|
+
rescue Errno::ENOENT, Errno::EACCES, Timeout::Error => e
|
|
202
|
+
raise BinaryUnavailable,
|
|
203
|
+
"OpenCode local inspection command could not run (#{e.class.name.split('::').last})"
|
|
204
|
+
end
|
|
205
|
+
private_class_method :capture!
|
|
206
|
+
|
|
207
|
+
def advertised?(help, flag)
|
|
208
|
+
help.match?(/(?:\A|[\s,])#{Regexp.escape(flag)}(?:[\s,=\[]|$)/)
|
|
209
|
+
end
|
|
210
|
+
private_class_method :advertised?
|
|
211
|
+
|
|
212
|
+
def variants_for(output, route)
|
|
213
|
+
text = normalized_output(output)
|
|
214
|
+
lines = text.lines
|
|
215
|
+
index = lines.index { |line| line.strip == route }
|
|
216
|
+
return nil unless index
|
|
217
|
+
|
|
218
|
+
suffix = lines.drop(index + 1).join
|
|
219
|
+
object = first_json_object(suffix)
|
|
220
|
+
return [] unless object
|
|
221
|
+
|
|
222
|
+
parsed = JSON.parse(object)
|
|
223
|
+
variants = parsed["variants"]
|
|
224
|
+
variants.is_a?(Hash) ? variants.keys.sort.freeze : [].freeze
|
|
225
|
+
rescue JSON::ParserError
|
|
226
|
+
raise ConfigurationError,
|
|
227
|
+
"OpenCode local model inventory is malformed"
|
|
228
|
+
end
|
|
229
|
+
private_class_method :variants_for
|
|
230
|
+
|
|
231
|
+
def first_json_object(text)
|
|
232
|
+
start = text.index("{")
|
|
233
|
+
return nil unless start
|
|
234
|
+
|
|
235
|
+
depth = 0
|
|
236
|
+
quoted = false
|
|
237
|
+
escaped = false
|
|
238
|
+
text.each_char.with_index do |character, index|
|
|
239
|
+
next if index < start
|
|
240
|
+
|
|
241
|
+
if quoted
|
|
242
|
+
if escaped
|
|
243
|
+
escaped = false
|
|
244
|
+
elsif character == "\\"
|
|
245
|
+
escaped = true
|
|
246
|
+
elsif character == '"'
|
|
247
|
+
quoted = false
|
|
248
|
+
end
|
|
249
|
+
next
|
|
250
|
+
end
|
|
251
|
+
if character == '"'
|
|
252
|
+
quoted = true
|
|
253
|
+
elsif character == "{"
|
|
254
|
+
depth += 1
|
|
255
|
+
elsif character == "}"
|
|
256
|
+
depth -= 1
|
|
257
|
+
return text[start..index] if depth.zero?
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
nil
|
|
261
|
+
end
|
|
262
|
+
private_class_method :first_json_object
|
|
263
|
+
|
|
264
|
+
def normalized_output(value)
|
|
265
|
+
value.to_s.gsub(ANSI_PATTERN, "").strip
|
|
266
|
+
end
|
|
267
|
+
private_class_method :normalized_output
|
|
268
|
+
|
|
269
|
+
def evidence(profile, capability, arguments = [])
|
|
270
|
+
CapabilityEvidence.new(
|
|
271
|
+
capability: capability,
|
|
272
|
+
supported: true,
|
|
273
|
+
provider: profile.name,
|
|
274
|
+
launcher_identity: profile.launcher_identity,
|
|
275
|
+
arguments: arguments.compact
|
|
276
|
+
)
|
|
277
|
+
end
|
|
278
|
+
private_class_method :evidence
|
|
279
|
+
|
|
280
|
+
def capability_for(flag)
|
|
281
|
+
{
|
|
282
|
+
"--model" => :model,
|
|
283
|
+
"--variant" => :model_variant,
|
|
284
|
+
"--format" => :json_events,
|
|
285
|
+
"--dir" => :working_directory,
|
|
286
|
+
"--pure" => :pure,
|
|
287
|
+
"--auto" => :permission_enforcement
|
|
288
|
+
}.fetch(flag)
|
|
289
|
+
end
|
|
290
|
+
private_class_method :capability_for
|
|
291
|
+
|
|
292
|
+
def error_capability(error)
|
|
293
|
+
case error
|
|
294
|
+
when AuthenticationError then :auth_configuration
|
|
295
|
+
when RouteUnavailable then :model_route
|
|
296
|
+
when UnsupportedCapability then :cli_capability
|
|
297
|
+
when BinaryUnavailable then :installation
|
|
298
|
+
else :probe
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
private_class_method :error_capability
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|