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.
@@ -25,7 +25,10 @@ module AgentCliRuntime
25
25
  :budget_flag, :output_format_flags, :version_flag,
26
26
  :min_version, :prompt_style, :model_argument_builder,
27
27
  :effort_argument_builder, :launcher_identity,
28
- :cli_capabilities, :declared_capability_support
28
+ :cli_capabilities, :declared_capability_support,
29
+ :credential_environment_keys, :configuration_environment_key,
30
+ :default_configuration_directory,
31
+ :permission_policy_required, :result_parser
29
32
 
30
33
  def initialize(name:, bin_default:, headless_flag:, version_flag:,
31
34
  env_bin_override_keys: [], permission_skip_flag: nil,
@@ -35,7 +38,10 @@ module AgentCliRuntime
35
38
  prompt_style: :positional, model_argument_builder: nil,
36
39
  effort_argument_builder: nil, launcher_identity: nil,
37
40
  usage_extractor: nil, auth_configuration_probe: nil,
38
- cli_capabilities: {}, raw_cli_arguments_supported: false)
41
+ cli_capabilities: {}, raw_cli_arguments_supported: false,
42
+ credential_environment_keys: [], configuration_environment_key: nil,
43
+ default_configuration_directory: nil,
44
+ permission_policy_required: false, result_parser: nil)
39
45
  normalized_prompt_style = prompt_style.to_sym
40
46
  unless PROMPT_STYLES.include?(normalized_prompt_style)
41
47
  raise ArgumentError,
@@ -65,6 +71,17 @@ module AgentCliRuntime
65
71
  @auth_configuration_probe = auth_configuration_probe
66
72
  @cli_capabilities = normalize_cli_capabilities(cli_capabilities)
67
73
  @raw_cli_arguments_supported = raw_cli_arguments_supported == true
74
+ @credential_environment_keys = immutable_environment_keys(
75
+ credential_environment_keys
76
+ )
77
+ @configuration_environment_key = optional_environment_key(
78
+ configuration_environment_key
79
+ )
80
+ @default_configuration_directory = optional_relative_directory(
81
+ default_configuration_directory
82
+ )
83
+ @permission_policy_required = permission_policy_required == true
84
+ @result_parser = result_parser
68
85
  @declared_capability_support = build_declared_capability_support
69
86
  freeze
70
87
  end
@@ -89,6 +106,10 @@ module AgentCliRuntime
89
106
  raise ArgumentError,
90
107
  "agent profile #{@name.inspect} cannot enforce read-only sandboxing"
91
108
  end
109
+ if permission_mode.nil? && @permission_policy_required
110
+ raise ConfigurationError,
111
+ "agent profile #{@name.inspect} requires an explicit OpenCode permission policy"
112
+ end
92
113
  return [] unless @permission_skip_flag
93
114
  return [ @permission_skip_flag ] unless @name == :claude && permission_mode
94
115
  return [ @permission_skip_flag ] if permission_mode == "bypassPermissions"
@@ -200,6 +221,44 @@ module AgentCliRuntime
200
221
  nil
201
222
  end
202
223
 
224
+ def parse_run(stdout)
225
+ unless @result_parser
226
+ raise UnsupportedCapability,
227
+ "agent profile #{@name.inspect} has no strict result parser"
228
+ end
229
+
230
+ @result_parser.parse_run(stdout)
231
+ end
232
+
233
+ def normalize_captured_result(captured, requested_route:)
234
+ unless @result_parser
235
+ raise UnsupportedCapability,
236
+ "agent profile #{@name.inspect} has no strict result parser"
237
+ end
238
+
239
+ @result_parser.normalize(
240
+ captured, requested_route: requested_route, profile: self
241
+ )
242
+ end
243
+
244
+ def configuration_directory(home: nil, env: ENV)
245
+ if @configuration_environment_key
246
+ configured = env[@configuration_environment_key].to_s
247
+ unless configured.empty?
248
+ unless File.absolute_path?(configured)
249
+ raise ArgumentError,
250
+ "#{@configuration_environment_key} must be an absolute directory"
251
+ end
252
+ return File.expand_path(configured)
253
+ end
254
+ end
255
+ return nil unless @default_configuration_directory
256
+
257
+ base = home || env["HOME"]
258
+ base = Dir.home if base.to_s.empty?
259
+ File.expand_path(@default_configuration_directory, base)
260
+ end
261
+
203
262
  def require_cli_capability!(capability)
204
263
  capability_name = capability.to_sym
205
264
  flags = @cli_capabilities[capability_name]
@@ -219,6 +278,12 @@ module AgentCliRuntime
219
278
  flags.dup
220
279
  end
221
280
 
281
+ def capture_local(*arguments, env: ENV, timeout_sec: CAPTURE_TIMEOUT_SECONDS)
282
+ bounded_capture3(
283
+ bin(env:), *arguments, timeout_sec: timeout_sec, env: env
284
+ )
285
+ end
286
+
222
287
  private
223
288
 
224
289
  def immutable_string(value)
@@ -229,6 +294,35 @@ module AgentCliRuntime
229
294
  Array(values).map { |value| immutable_string(value) }.freeze
230
295
  end
231
296
 
297
+ def immutable_environment_keys(values)
298
+ keys = immutable_strings(values)
299
+ invalid = keys.find { |key| !key.match?(/\A[A-Z][A-Z0-9_]*\z/) }
300
+ raise ArgumentError, "invalid credential environment key #{invalid.inspect}" if invalid
301
+ raise ArgumentError, "credential environment keys must be unique" if keys.uniq.length != keys.length
302
+
303
+ keys
304
+ end
305
+
306
+ def optional_environment_key(value)
307
+ return nil if value.nil?
308
+
309
+ key = immutable_string(value)
310
+ unless key.match?(/\A[A-Z][A-Z0-9_]*\z/)
311
+ raise ArgumentError, "invalid configuration environment key #{key.inspect}"
312
+ end
313
+ key
314
+ end
315
+
316
+ def optional_relative_directory(value)
317
+ return nil if value.nil?
318
+
319
+ path = immutable_string(value)
320
+ if path.empty? || File.absolute_path?(path)
321
+ raise ArgumentError, "default configuration directory must be relative"
322
+ end
323
+ path
324
+ end
325
+
232
326
  def normalize_tool_scope_flags(flags)
233
327
  unless flags.is_a?(Hash)
234
328
  raise ArgumentError, "tool_scope_flags must be a Hash"
@@ -1,5 +1,52 @@
1
1
  module AgentCliRuntime
2
2
  module Profiles
3
+ PI_CREDENTIAL_ENVIRONMENT_KEYS = %w[
4
+ AI_GATEWAY_API_KEY
5
+ ANTHROPIC_API_KEY
6
+ ANTHROPIC_AUTH_TOKEN
7
+ ANTHROPIC_OAUTH_TOKEN
8
+ ANT_LING_API_KEY
9
+ AWS_ACCESS_KEY_ID
10
+ AWS_BEARER_TOKEN_BEDROCK
11
+ AWS_CONTAINER_CREDENTIALS_FULL_URI
12
+ AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
13
+ AWS_PROFILE
14
+ AWS_SECRET_ACCESS_KEY
15
+ AWS_SESSION_TOKEN
16
+ AWS_WEB_IDENTITY_TOKEN_FILE
17
+ AZURE_OPENAI_API_KEY
18
+ BASETEN_API_KEY
19
+ CEREBRAS_API_KEY
20
+ CLOUDFLARE_API_KEY
21
+ COPILOT_GITHUB_TOKEN
22
+ DEEPSEEK_API_KEY
23
+ FIREWORKS_API_KEY
24
+ GEMINI_API_KEY
25
+ GOOGLE_APPLICATION_CREDENTIALS
26
+ GOOGLE_CLOUD_API_KEY
27
+ GROQ_API_KEY
28
+ HF_TOKEN
29
+ KIMI_API_KEY
30
+ MINIMAX_API_KEY
31
+ MINIMAX_CN_API_KEY
32
+ MISTRAL_API_KEY
33
+ MOONSHOT_API_KEY
34
+ NVIDIA_API_KEY
35
+ OPENCODE_API_KEY
36
+ OPENAI_API_KEY
37
+ OPENROUTER_API_KEY
38
+ QWEN_TOKEN_PLAN_API_KEY
39
+ RADIUS_API_KEY
40
+ TOGETHER_API_KEY
41
+ XAI_API_KEY
42
+ XIAOMI_API_KEY
43
+ XIAOMI_TOKEN_PLAN_AMS_API_KEY
44
+ XIAOMI_TOKEN_PLAN_CN_API_KEY
45
+ XIAOMI_TOKEN_PLAN_SGP_API_KEY
46
+ ZAI_API_KEY
47
+ ZAI_CODING_CN_API_KEY
48
+ ].freeze
49
+
3
50
  module_function
4
51
 
5
52
  def names
@@ -56,6 +103,37 @@ module AgentCliRuntime
56
103
  home_path(home, ".grok", "auth.json")
57
104
  end
58
105
 
106
+ def opencode_auth_path(home:, env:)
107
+ data_home = env["XDG_DATA_HOME"]
108
+ if !data_home.to_s.empty?
109
+ unless File.absolute_path?(data_home)
110
+ raise ArgumentError, "XDG_DATA_HOME must be absolute"
111
+ end
112
+
113
+ return File.join(data_home, "opencode", "auth.json")
114
+ end
115
+
116
+ home_path(home, ".local", "share", "opencode", "auth.json")
117
+ end
118
+
119
+ def opencode_model_arguments(model)
120
+ route = Route.parse(model)
121
+ [ "--model", route.to_s ]
122
+ end
123
+
124
+ OPENCODE_VARIANTS = %w[minimal low medium high xhigh max].freeze
125
+
126
+ def opencode_variant_arguments(variant)
127
+ normalized = variant.to_s
128
+ unless OPENCODE_VARIANTS.include?(normalized)
129
+ raise ArgumentError,
130
+ "unsupported OpenCode variant #{variant.inspect}; expected one of " \
131
+ "#{OPENCODE_VARIANTS.join(', ')}"
132
+ end
133
+
134
+ [ "--variant", normalized ]
135
+ end
136
+
59
137
  CLAUDE = Profile.new(
60
138
  name: :claude,
61
139
  bin_default: "claude",
@@ -78,6 +156,11 @@ module AgentCliRuntime
78
156
  effort_argument_builder: ->(effort) { [ "--effort", effort ] },
79
157
  launcher_identity: "claude-code/v1",
80
158
  usage_extractor: UsageExtractors::CLAUDE,
159
+ credential_environment_keys: %w[
160
+ ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_API_KEY
161
+ ],
162
+ configuration_environment_key: "CLAUDE_CONFIG_DIR",
163
+ default_configuration_directory: ".claude",
81
164
  raw_cli_arguments_supported: true,
82
165
  auth_configuration_probe: lambda do |home:, env:|
83
166
  if env_configured?(env, "ANTHROPIC_API_KEY")
@@ -117,6 +200,9 @@ module AgentCliRuntime
117
200
  ->(effort) { [ "-c", "model_reasoning_effort=#{effort}" ] },
118
201
  launcher_identity: "codex-cli/v1",
119
202
  usage_extractor: UsageExtractors::CODEX,
203
+ credential_environment_keys: %w[OPENAI_API_KEY],
204
+ configuration_environment_key: "CODEX_HOME",
205
+ default_configuration_directory: ".codex",
120
206
  auth_configuration_probe: lambda do |home:, env:|
121
207
  if env_configured?(env, "OPENAI_API_KEY")
122
208
  AuthConfiguration.new(status: :configured, source: "environment")
@@ -140,6 +226,9 @@ module AgentCliRuntime
140
226
  model_argument_builder: ->(model) { [ "--model", model ] },
141
227
  launcher_identity: "pi-coding-agent/v1",
142
228
  usage_extractor: UsageExtractors::PI,
229
+ credential_environment_keys: PI_CREDENTIAL_ENVIRONMENT_KEYS,
230
+ configuration_environment_key: "PI_CODING_AGENT_DIR",
231
+ default_configuration_directory: ".pi/agent",
143
232
  auth_configuration_probe: lambda do |home:, env:|
144
233
  directory = env["PI_CODING_AGENT_DIR"]
145
234
  path =
@@ -169,6 +258,9 @@ module AgentCliRuntime
169
258
  ->(effort) { [ "--reasoning-effort", effort ] },
170
259
  launcher_identity: "grok-cli/v1",
171
260
  usage_extractor: UsageExtractors::GROK,
261
+ credential_environment_keys: %w[GROK_AUTH_PATH XAI_API_KEY GROK_CODE_XAI_API_KEY],
262
+ configuration_environment_key: "GROK_HOME",
263
+ default_configuration_directory: ".grok",
172
264
  auth_configuration_probe: lambda do |home:, env:|
173
265
  if env_configured?(env, "XAI_API_KEY", "GROK_CODE_XAI_API_KEY")
174
266
  AuthConfiguration.new(status: :configured, source: "environment")
@@ -181,7 +273,45 @@ module AgentCliRuntime
181
273
  end
182
274
  )
183
275
 
184
- PROFILES = [ CLAUDE, CODEX, PI, GROK ].to_h do |profile|
276
+ OPENCODE = Profile.new(
277
+ name: :opencode,
278
+ bin_default: "opencode",
279
+ env_bin_override_keys: %w[
280
+ AGENT_CLI_RUNTIME_OPENCODE_BIN HIVE_OPENCODE_BIN
281
+ ],
282
+ headless_flag: "run",
283
+ output_format_flags: [ "--format", "json" ],
284
+ version_flag: "--version",
285
+ min_version: "1.18.16",
286
+ model_argument_builder: ->(model) { opencode_model_arguments(model) },
287
+ effort_argument_builder:
288
+ ->(variant) { opencode_variant_arguments(variant) },
289
+ launcher_identity: "opencode-cli/v1",
290
+ credential_environment_keys: PI_CREDENTIAL_ENVIRONMENT_KEYS,
291
+ configuration_environment_key: "OPENCODE_CONFIG_DIR",
292
+ default_configuration_directory: ".config/opencode",
293
+ permission_policy_required: true,
294
+ result_parser: OpenCode::ResultParser,
295
+ cli_capabilities: {
296
+ json_events: [ "run", "--format" ],
297
+ working_directory: [ "run", "--dir" ],
298
+ model_variant: [ "run", "--variant" ],
299
+ pure: [ "run", "--pure" ],
300
+ sanitized_export: [ "export", "--sanitize" ]
301
+ },
302
+ auth_configuration_probe: lambda do |home:, env:|
303
+ if env_configured?(env, *PI_CREDENTIAL_ENVIRONMENT_KEYS)
304
+ AuthConfiguration.new(status: :configured, source: "environment")
305
+ else
306
+ auth_from_file(
307
+ opencode_auth_path(home:, env:),
308
+ source: "opencode auth.json"
309
+ )
310
+ end
311
+ end
312
+ )
313
+
314
+ PROFILES = [ CLAUDE, CODEX, PI, GROK, OPENCODE ].to_h do |profile|
185
315
  [ profile.name, profile ]
186
316
  end.freeze
187
317
  PROVIDER_ORDER = PROFILES.keys.freeze
@@ -63,9 +63,13 @@ module AgentCliRuntime
63
63
  compilation_error!(profile, e)
64
64
  end
65
65
 
66
- def prepare!(profile)
66
+ def prepare!(profile, env: ENV)
67
+ if profile.is_a?(OpenCodePreparationRequest)
68
+ return OpenCode::Overlay.prepare!(profile, env:)
69
+ end
70
+
67
71
  resolved = Profiles.resolve(profile)
68
- result = Probe.call(resolved)
72
+ result = Probe.call(resolved, env:)
69
73
  return result if result.ready
70
74
 
71
75
  evidence = unsupported_evidence(
@@ -116,7 +120,18 @@ module AgentCliRuntime
116
120
  final_message: raw[:final_message],
117
121
  diagnostic: Redactor.diagnostic(
118
122
  raw[:error_message] || raw[:limit_text]
119
- )
123
+ ),
124
+ provider_signal: raw[:provider_signal]
125
+ )
126
+ end
127
+
128
+ def parse_run(profile, stdout:)
129
+ Profiles.resolve(profile).parse_run(stdout)
130
+ end
131
+
132
+ def normalize(profile, captured, requested_route:)
133
+ Profiles.resolve(profile).normalize_captured_result(
134
+ captured, requested_route:
120
135
  )
121
136
  end
122
137