agent-cli-runtime 0.1.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.
@@ -0,0 +1,390 @@
1
+ require "open3"
2
+ require "timeout"
3
+ require "rubygems/version"
4
+
5
+ module AgentCliRuntime
6
+ class Profile
7
+ PROMPT_STYLES = %i[positional headless_flag_value stdin].freeze
8
+ WORKSPACE_WRITE_PERMISSION_MODE = "workspace-write".freeze
9
+ READ_ONLY_PERMISSION_MODE = "read-only".freeze
10
+ CAPTURE_TIMEOUT_SECONDS = 10
11
+ CAPTURE_POLL_SECONDS = 0.01
12
+ CAPTURE_TERM_GRACE_SECONDS = 0.2
13
+ CAPTURE_REAP_GRACE_SECONDS = 0.2
14
+ VERSION_TOKEN_PATTERN =
15
+ /(?<![0-9A-Za-z])v?(\d+\.\d+\.\d+(?:[.-][0-9A-Za-z]+)*)(?![0-9A-Za-z])/
16
+ RESERVED_CAPABILITY_NAMES = %i[
17
+ headless version auth_configuration add_directory allowed_tools
18
+ disallowed_tools model effort budget raw_cli_arguments installation
19
+ ].freeze
20
+ private_constant :VERSION_TOKEN_PATTERN, :RESERVED_CAPABILITY_NAMES
21
+
22
+ attr_reader :name, :bin_default, :env_bin_override_keys, :headless_flag,
23
+ :permission_skip_flag, :workspace_write_flags,
24
+ :read_only_flags, :add_dir_flag, :tool_scope_flags,
25
+ :budget_flag, :output_format_flags, :version_flag,
26
+ :min_version, :prompt_style, :model_argument_builder,
27
+ :effort_argument_builder, :launcher_identity,
28
+ :cli_capabilities, :declared_capability_support
29
+
30
+ def initialize(name:, bin_default:, headless_flag:, version_flag:,
31
+ env_bin_override_keys: [], permission_skip_flag: nil,
32
+ workspace_write_flags: [], read_only_flags: [],
33
+ add_dir_flag: nil, tool_scope_flags: {}, budget_flag: nil,
34
+ output_format_flags: [], min_version: nil,
35
+ prompt_style: :positional, model_argument_builder: nil,
36
+ effort_argument_builder: nil, launcher_identity: nil,
37
+ usage_extractor: nil, auth_configuration_probe: nil,
38
+ cli_capabilities: {}, raw_cli_arguments_supported: false)
39
+ normalized_prompt_style = prompt_style.to_sym
40
+ unless PROMPT_STYLES.include?(normalized_prompt_style)
41
+ raise ArgumentError,
42
+ "unknown prompt_style #{prompt_style.inspect}; valid: #{PROMPT_STYLES.inspect}"
43
+ end
44
+
45
+ @name = name.to_sym
46
+ @bin_default = immutable_string(bin_default)
47
+ @env_bin_override_keys = immutable_strings(env_bin_override_keys)
48
+ @headless_flag = immutable_string(headless_flag)
49
+ @permission_skip_flag =
50
+ permission_skip_flag.nil? ? nil : immutable_string(permission_skip_flag)
51
+ @workspace_write_flags = immutable_strings(workspace_write_flags)
52
+ @read_only_flags = immutable_strings(read_only_flags)
53
+ @add_dir_flag = add_dir_flag.nil? ? nil : immutable_string(add_dir_flag)
54
+ @tool_scope_flags = normalize_tool_scope_flags(tool_scope_flags)
55
+ @budget_flag = budget_flag.nil? ? nil : immutable_string(budget_flag)
56
+ @output_format_flags = immutable_strings(output_format_flags)
57
+ @version_flag = immutable_string(version_flag)
58
+ @min_version = min_version.nil? ? nil : immutable_string(min_version)
59
+ @prompt_style = normalized_prompt_style
60
+ @model_argument_builder = model_argument_builder
61
+ @effort_argument_builder = effort_argument_builder
62
+ @launcher_identity =
63
+ immutable_string(launcher_identity || "agent-cli-runtime/v1:#{@name}")
64
+ @usage_extractor = usage_extractor || ->(_event) { nil }
65
+ @auth_configuration_probe = auth_configuration_probe
66
+ @cli_capabilities = normalize_cli_capabilities(cli_capabilities)
67
+ @raw_cli_arguments_supported = raw_cli_arguments_supported == true
68
+ @declared_capability_support = build_declared_capability_support
69
+ freeze
70
+ end
71
+
72
+ def bin(env: ENV)
73
+ key = @env_bin_override_keys.find do |candidate|
74
+ !env[candidate].to_s.empty?
75
+ end
76
+ key ? env.fetch(key) : @bin_default
77
+ end
78
+
79
+ def permission_flags(permission_mode = nil)
80
+ if permission_mode == WORKSPACE_WRITE_PERMISSION_MODE
81
+ return @workspace_write_flags.dup unless @workspace_write_flags.empty?
82
+
83
+ raise ArgumentError,
84
+ "agent profile #{@name.inspect} cannot enforce workspace-write sandboxing"
85
+ end
86
+ if permission_mode == READ_ONLY_PERMISSION_MODE
87
+ return @read_only_flags.dup unless @read_only_flags.empty?
88
+
89
+ raise ArgumentError,
90
+ "agent profile #{@name.inspect} cannot enforce read-only sandboxing"
91
+ end
92
+ return [] unless @permission_skip_flag
93
+ return [ @permission_skip_flag ] unless @name == :claude && permission_mode
94
+ return [ @permission_skip_flag ] if permission_mode == "bypassPermissions"
95
+
96
+ [ "--permission-mode", permission_mode.to_s ]
97
+ end
98
+
99
+ def identity_arguments(model:, effort:, pin_model: true)
100
+ normalized_model = nonempty_value(model, "model")
101
+ normalized_effort = effort.nil? ? nil : nonempty_value(effort, "effort")
102
+ native_arguments = []
103
+ if pin_model
104
+ unless @model_argument_builder
105
+ raise UnsupportedCapability,
106
+ "agent profile #{@name.inspect} cannot pin a model"
107
+ end
108
+ native_arguments.concat(@model_argument_builder.call(normalized_model))
109
+ end
110
+
111
+ effective_effort = nil
112
+ if normalized_effort
113
+ unless @effort_argument_builder
114
+ raise UnsupportedCapability,
115
+ "agent profile #{@name.inspect} cannot set reasoning effort"
116
+ end
117
+ native_arguments.concat(@effort_argument_builder.call(normalized_effort))
118
+ effective_effort = normalized_effort
119
+ end
120
+
121
+ IdentityArguments.new(
122
+ model: normalized_model,
123
+ requested_effort: normalized_effort,
124
+ effective_effort: effective_effort,
125
+ effort_supported: !@effort_argument_builder.nil?,
126
+ model_pinned: pin_model,
127
+ native_arguments: validate_arguments(native_arguments)
128
+ )
129
+ end
130
+
131
+ def raw_cli_arguments_supported?
132
+ @raw_cli_arguments_supported
133
+ end
134
+
135
+ def binary_installed?(env: ENV)
136
+ executable = bin(env:)
137
+ return File.file?(executable) && File.executable?(executable) if executable.include?(File::SEPARATOR)
138
+
139
+ env.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |directory|
140
+ candidate = File.join(directory, executable)
141
+ File.file?(candidate) && File.executable?(candidate)
142
+ end
143
+ rescue ArgumentError
144
+ false
145
+ end
146
+
147
+ def check_version!(env: ENV)
148
+ executable = bin(env:)
149
+ out, _err, status = bounded_capture3(
150
+ executable, @version_flag, timeout_sec: CAPTURE_TIMEOUT_SECONDS, env: env
151
+ )
152
+ unless status.success?
153
+ raise BinaryUnavailable,
154
+ "#{@name} binary not runnable: #{executable}"
155
+ end
156
+
157
+ versions = out.scan(VERSION_TOKEN_PATTERN).flatten.uniq
158
+ if versions.empty?
159
+ raise VersionError,
160
+ "could not parse #{@name} #{@version_flag} output"
161
+ end
162
+ if versions.length > 1
163
+ raise VersionError,
164
+ "ambiguous #{@name} #{@version_flag} output: " \
165
+ "#{versions.join(', ')}"
166
+ end
167
+ version = versions.fetch(0)
168
+ if @min_version &&
169
+ Gem::Version.new(version) < Gem::Version.new(@min_version)
170
+ raise VersionError,
171
+ "#{@name} #{version} below minimum #{@min_version}"
172
+ end
173
+ version.freeze
174
+ rescue Errno::ENOENT, Errno::EACCES => e
175
+ raise BinaryUnavailable,
176
+ "#{@name} binary not runnable: #{executable} (#{e.class.name.split('::').last})"
177
+ rescue Timeout::Error
178
+ raise BinaryUnavailable,
179
+ "#{@name} version check timed out after #{CAPTURE_TIMEOUT_SECONDS}s: #{executable}"
180
+ end
181
+
182
+ def auth_configuration(home: nil, env: ENV)
183
+ return AuthConfiguration.new(status: :not_checked) unless @auth_configuration_probe
184
+
185
+ result = @auth_configuration_probe.call(home: home, env: env)
186
+ return result if result.is_a?(AuthConfiguration)
187
+
188
+ raise TypeError,
189
+ "auth_configuration_probe must return AgentCliRuntime::AuthConfiguration"
190
+ rescue StandardError => e
191
+ AuthConfiguration.new(
192
+ status: :missing,
193
+ diagnostic: Redactor.diagnostic(e)
194
+ )
195
+ end
196
+
197
+ def extract_usage_event(event)
198
+ @usage_extractor.call(event)
199
+ rescue StandardError
200
+ nil
201
+ end
202
+
203
+ def require_cli_capability!(capability)
204
+ capability_name = capability.to_sym
205
+ flags = @cli_capabilities[capability_name]
206
+ unless flags
207
+ raise UnsupportedCapability,
208
+ "agent profile #{@name.inspect} does not declare CLI capability " \
209
+ "#{capability_name.inspect}"
210
+ end
211
+
212
+ help = capture_help(flags)
213
+ missing = flags.grep(/\A-/).reject { |flag| flag_advertised?(help, flag) }
214
+ unless missing.empty?
215
+ raise UnsupportedCapability,
216
+ "#{@name} does not advertise required #{capability_name} capability " \
217
+ "(missing #{missing.join(', ')})"
218
+ end
219
+ flags.dup
220
+ end
221
+
222
+ private
223
+
224
+ def immutable_string(value)
225
+ value.to_s.dup.freeze
226
+ end
227
+
228
+ def immutable_strings(values)
229
+ Array(values).map { |value| immutable_string(value) }.freeze
230
+ end
231
+
232
+ def normalize_tool_scope_flags(flags)
233
+ unless flags.is_a?(Hash)
234
+ raise ArgumentError, "tool_scope_flags must be a Hash"
235
+ end
236
+
237
+ flags.each_with_object({}) do |(scope, flag), normalized|
238
+ name = scope.to_sym
239
+ unless %i[allowed disallowed].include?(name)
240
+ raise ArgumentError, "unknown tool scope #{scope.inspect}"
241
+ end
242
+ normalized[name] = nonempty_value(flag, "tool scope flag")
243
+ end.freeze
244
+ end
245
+
246
+ def normalize_cli_capabilities(capabilities)
247
+ unless capabilities.is_a?(Hash)
248
+ raise ArgumentError, "cli_capabilities must be a Hash"
249
+ end
250
+
251
+ capabilities.each_with_object({}) do |(name, flags), normalized|
252
+ capability_name = name.to_sym
253
+ if RESERVED_CAPABILITY_NAMES.include?(capability_name)
254
+ raise ArgumentError,
255
+ "CLI capability #{name.inspect} collides with a standard capability"
256
+ end
257
+ values = immutable_strings(flags)
258
+ raise ArgumentError, "CLI capability #{name.inspect} is empty" if values.empty?
259
+
260
+ normalized[capability_name] = values
261
+ end.freeze
262
+ end
263
+
264
+ def build_declared_capability_support
265
+ support = {
266
+ headless: true,
267
+ add_directory: !@add_dir_flag.nil?,
268
+ allowed_tools: @tool_scope_flags.key?(:allowed),
269
+ disallowed_tools: @tool_scope_flags.key?(:disallowed),
270
+ model: !@model_argument_builder.nil?,
271
+ effort: !@effort_argument_builder.nil?,
272
+ budget: !@budget_flag.nil?,
273
+ raw_cli_arguments: @raw_cli_arguments_supported
274
+ }
275
+ @cli_capabilities.each_key { |capability| support[capability] = true }
276
+ support.freeze
277
+ end
278
+
279
+ def nonempty_value(value, label)
280
+ string = value.to_s
281
+ if string.empty? || string.include?("\0")
282
+ raise ArgumentError, "#{label} must be a non-empty string without NUL bytes"
283
+ end
284
+ string.freeze
285
+ end
286
+
287
+ def validate_arguments(arguments)
288
+ Array(arguments).map do |argument|
289
+ nonempty_value(argument, "native argument")
290
+ end
291
+ end
292
+
293
+ def capture_help(flags)
294
+ out, err, status = bounded_capture3(
295
+ bin, *flags, "--help", timeout_sec: CAPTURE_TIMEOUT_SECONDS
296
+ )
297
+ unless status.success?
298
+ raise UnsupportedCapability,
299
+ "#{@name} capability check failed"
300
+ end
301
+ "#{out}\n#{err}"
302
+ rescue Errno::ENOENT, Errno::EACCES, Timeout::Error => e
303
+ raise UnsupportedCapability,
304
+ "#{@name} capability check could not run (#{e.class.name.split('::').last})"
305
+ end
306
+
307
+ def flag_advertised?(help, flag)
308
+ help.match?(/(?:\A|[\s,])#{Regexp.escape(flag)}(?:[\s,=\[]|$)/)
309
+ end
310
+
311
+ def bounded_capture3(*argv, timeout_sec:, env: nil)
312
+ popen_arguments = env ? [ env, *argv ] : argv
313
+ stdin, stdout, stderr, waiter =
314
+ Open3.popen3(*popen_arguments, pgroup: true)
315
+ stdin.close
316
+ readers = [ capture_reader(stdout), capture_reader(stderr) ]
317
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_sec
318
+
319
+ loop do
320
+ unless waiter.alive?
321
+ status = waiter.value
322
+ return [ readers[0].value, readers[1].value, status ] if readers.none?(&:alive?)
323
+ end
324
+
325
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
326
+ raise Timeout::Error if remaining <= 0
327
+
328
+ sleep [ CAPTURE_POLL_SECONDS, remaining ].min
329
+ end
330
+ rescue Timeout::Error
331
+ terminate_process_group(waiter) if waiter
332
+ stop_capture_readers(readers, stdout, stderr)
333
+ raise
334
+ ensure
335
+ [ stdin, stdout, stderr ].each do |io|
336
+ io.close if io && !io.closed?
337
+ rescue IOError
338
+ nil
339
+ end
340
+ end
341
+
342
+ def capture_reader(io)
343
+ Thread.new do
344
+ Thread.current.report_on_exception = false
345
+ io.read
346
+ rescue IOError
347
+ ""
348
+ end
349
+ end
350
+
351
+ def terminate_process_group(waiter)
352
+ pid = waiter.pid
353
+ signal_process_group("TERM", pid)
354
+ deadline =
355
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) +
356
+ CAPTURE_TERM_GRACE_SECONDS
357
+ while process_group_alive?(pid) &&
358
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
359
+ sleep CAPTURE_POLL_SECONDS
360
+ end
361
+ signal_process_group("KILL", pid) if process_group_alive?(pid)
362
+ waiter.join(CAPTURE_REAP_GRACE_SECONDS)
363
+ end
364
+
365
+ def stop_capture_readers(readers, *streams)
366
+ Array(readers).each { |reader| reader.join(CAPTURE_REAP_GRACE_SECONDS) }
367
+ streams.each do |stream|
368
+ stream.close if stream && !stream.closed?
369
+ rescue IOError
370
+ nil
371
+ end
372
+ Array(readers).each { |reader| reader.kill if reader.alive? }
373
+ end
374
+
375
+ def signal_process_group(signal, pid)
376
+ Process.kill(signal, -Integer(pid))
377
+ rescue Errno::ESRCH, Errno::ECHILD
378
+ nil
379
+ end
380
+
381
+ def process_group_alive?(pid)
382
+ Process.kill(0, -Integer(pid))
383
+ true
384
+ rescue Errno::ESRCH, Errno::ECHILD
385
+ false
386
+ rescue Errno::EPERM
387
+ true
388
+ end
389
+ end
390
+ end
@@ -0,0 +1,190 @@
1
+ module AgentCliRuntime
2
+ module Profiles
3
+ module_function
4
+
5
+ def names
6
+ PROVIDER_ORDER
7
+ end
8
+
9
+ def fetch(name)
10
+ PROFILES.fetch(name.to_sym) do
11
+ raise UnknownProvider,
12
+ "unknown provider #{name.inspect}; expected one of #{PROVIDER_ORDER.join(', ')}"
13
+ end
14
+ end
15
+
16
+ def resolve(profile)
17
+ profile.is_a?(Profile) ? profile : fetch(profile)
18
+ end
19
+
20
+ def auth_from_file(path, source:)
21
+ configured = File.file?(path) &&
22
+ !File.read(path).strip.match?(/\A(?:|\{\s*\})\z/m)
23
+ AuthConfiguration.new(
24
+ status: configured ? :configured : :missing,
25
+ source: source
26
+ )
27
+ rescue SystemCallError, ArgumentError => e
28
+ AuthConfiguration.new(
29
+ status: :missing,
30
+ source: source,
31
+ diagnostic: Redactor.diagnostic(e)
32
+ )
33
+ end
34
+
35
+ def home_path(home, *parts)
36
+ File.join(home || Dir.home, *parts)
37
+ end
38
+
39
+ def env_configured?(env, *keys)
40
+ keys.any? { |key| !env[key].to_s.strip.empty? }
41
+ end
42
+
43
+ def grok_auth_path(home:, env:)
44
+ auth_path = env["GROK_AUTH_PATH"]
45
+ grok_home = env["GROK_HOME"]
46
+ if !auth_path.to_s.empty?
47
+ raise ArgumentError, "GROK_AUTH_PATH must be absolute" unless File.absolute_path?(auth_path)
48
+
49
+ return auth_path
50
+ end
51
+ if !grok_home.to_s.empty?
52
+ raise ArgumentError, "GROK_HOME must be absolute" unless File.absolute_path?(grok_home)
53
+
54
+ return File.join(grok_home, "auth.json")
55
+ end
56
+ home_path(home, ".grok", "auth.json")
57
+ end
58
+
59
+ CLAUDE = Profile.new(
60
+ name: :claude,
61
+ bin_default: "claude",
62
+ env_bin_override_keys: %w[AGENT_CLI_RUNTIME_CLAUDE_BIN HIVE_CLAUDE_BIN],
63
+ headless_flag: "-p",
64
+ permission_skip_flag: "--dangerously-skip-permissions",
65
+ add_dir_flag: "--add-dir",
66
+ tool_scope_flags: {
67
+ allowed: "--allowedTools",
68
+ disallowed: "--disallowedTools"
69
+ },
70
+ budget_flag: "--max-budget-usd",
71
+ output_format_flags: [
72
+ "--output-format", "stream-json",
73
+ "--include-partial-messages", "--verbose", "--no-session-persistence"
74
+ ],
75
+ version_flag: "--version",
76
+ min_version: "2.1.118",
77
+ model_argument_builder: ->(model) { [ "--model", model ] },
78
+ effort_argument_builder: ->(effort) { [ "--effort", effort ] },
79
+ launcher_identity: "claude-code/v1",
80
+ usage_extractor: UsageExtractors::CLAUDE,
81
+ raw_cli_arguments_supported: true,
82
+ auth_configuration_probe: lambda do |home:, env:|
83
+ if env_configured?(env, "ANTHROPIC_API_KEY")
84
+ AuthConfiguration.new(status: :configured, source: "environment")
85
+ else
86
+ auth_from_file(
87
+ home_path(home, ".claude", ".credentials.json"),
88
+ source: "~/.claude/.credentials.json"
89
+ )
90
+ end
91
+ end
92
+ )
93
+
94
+ CODEX = Profile.new(
95
+ name: :codex,
96
+ bin_default: "codex",
97
+ env_bin_override_keys: %w[AGENT_CLI_RUNTIME_CODEX_BIN HIVE_CODEX_BIN],
98
+ headless_flag: "exec",
99
+ prompt_style: :stdin,
100
+ permission_skip_flag: "--dangerously-bypass-approvals-and-sandbox",
101
+ workspace_write_flags: [
102
+ "--sandbox", "workspace-write",
103
+ "-c", 'approval_policy="never"',
104
+ "--ephemeral", "--ignore-user-config", "--ignore-rules"
105
+ ],
106
+ read_only_flags: [
107
+ "--sandbox", "read-only",
108
+ "-c", 'approval_policy="never"',
109
+ "--ephemeral", "--ignore-user-config", "--ignore-rules"
110
+ ],
111
+ add_dir_flag: "--add-dir",
112
+ output_format_flags: [ "--json" ],
113
+ version_flag: "--version",
114
+ min_version: "0.125.0",
115
+ model_argument_builder: ->(model) { [ "--model", model ] },
116
+ effort_argument_builder:
117
+ ->(effort) { [ "-c", "model_reasoning_effort=#{effort}" ] },
118
+ launcher_identity: "codex-cli/v1",
119
+ usage_extractor: UsageExtractors::CODEX,
120
+ auth_configuration_probe: lambda do |home:, env:|
121
+ if env_configured?(env, "OPENAI_API_KEY")
122
+ AuthConfiguration.new(status: :configured, source: "environment")
123
+ else
124
+ auth_from_file(
125
+ home_path(home, ".codex", "auth.json"),
126
+ source: "~/.codex/auth.json"
127
+ )
128
+ end
129
+ end
130
+ )
131
+
132
+ PI = Profile.new(
133
+ name: :pi,
134
+ bin_default: "pi",
135
+ env_bin_override_keys: %w[AGENT_CLI_RUNTIME_PI_BIN HIVE_PI_BIN],
136
+ headless_flag: "-p",
137
+ output_format_flags: [ "--mode", "json", "--no-session" ],
138
+ version_flag: "--version",
139
+ min_version: "0.70.2",
140
+ model_argument_builder: ->(model) { [ "--model", model ] },
141
+ launcher_identity: "pi-coding-agent/v1",
142
+ usage_extractor: UsageExtractors::PI,
143
+ auth_configuration_probe: lambda do |home:, env:|
144
+ directory = env["PI_CODING_AGENT_DIR"]
145
+ path =
146
+ if directory.to_s.empty?
147
+ home_path(home, ".pi", "agent", "auth.json")
148
+ else
149
+ raise ArgumentError, "PI_CODING_AGENT_DIR must be absolute" unless File.absolute_path?(directory)
150
+
151
+ File.join(directory, "auth.json")
152
+ end
153
+ auth_from_file(path, source: "pi auth.json")
154
+ end
155
+ )
156
+
157
+ GROK = Profile.new(
158
+ name: :grok,
159
+ bin_default: "grok",
160
+ env_bin_override_keys: %w[AGENT_CLI_RUNTIME_GROK_BIN HIVE_GROK_BIN],
161
+ headless_flag: "-p",
162
+ prompt_style: :headless_flag_value,
163
+ permission_skip_flag: "--always-approve",
164
+ output_format_flags: [ "--output-format", "streaming-json" ],
165
+ version_flag: "--version",
166
+ min_version: "0.2.90",
167
+ model_argument_builder: ->(model) { [ "--model", model ] },
168
+ effort_argument_builder:
169
+ ->(effort) { [ "--reasoning-effort", effort ] },
170
+ launcher_identity: "grok-cli/v1",
171
+ usage_extractor: UsageExtractors::GROK,
172
+ auth_configuration_probe: lambda do |home:, env:|
173
+ if env_configured?(env, "XAI_API_KEY", "GROK_CODE_XAI_API_KEY")
174
+ AuthConfiguration.new(status: :configured, source: "environment")
175
+ else
176
+ auth_from_file(
177
+ grok_auth_path(home: home, env: env),
178
+ source: "grok auth.json"
179
+ )
180
+ end
181
+ end
182
+ )
183
+
184
+ PROFILES = [ CLAUDE, CODEX, PI, GROK ].to_h do |profile|
185
+ [ profile.name, profile ]
186
+ end.freeze
187
+ PROVIDER_ORDER = PROFILES.keys.freeze
188
+ private_constant :PROFILES
189
+ end
190
+ end
@@ -0,0 +1,42 @@
1
+ module AgentCliRuntime
2
+ module Redactor
3
+ PATTERNS = {
4
+ github_token: /\b(?:github_pat_[A-Za-z0-9_]{20,}|gh[psou]_[A-Za-z0-9]{36,})\b/,
5
+ openai_api_key: /\bsk-[A-Za-z0-9]{20,}/,
6
+ anthropic_api_key: /\bsk-ant-[A-Za-z0-9_-]{20,}/,
7
+ generic_api_key:
8
+ /\bapi[_-]?key\b['"]?[\s:=]{0,3}['"]?[A-Za-z0-9_-]{20,}['"]?(?=[\s,;}\]]|$)/i,
9
+ password:
10
+ /\b(?:password|passwd|pwd)\b['"]?[\s:=]{0,3}['"]?[^\s'"]{6,}['"]?(?=[\s,;}\]]|$)/i,
11
+ authorization:
12
+ /\bauthorization\s*[:=]\s*['"]?(?:Bearer|Basic|Token)\s+[A-Za-z0-9._+\/=-]{8,}['"]?/i,
13
+ private_key:
14
+ /-----BEGIN (?:RSA |OPENSSH |EC |DSA |PGP )?PRIVATE KEY(?: BLOCK)?-----.*?-----END (?:RSA |OPENSSH |EC |DSA |PGP )?PRIVATE KEY(?: BLOCK)?-----/m,
15
+ private_key_header:
16
+ /-----BEGIN (?:RSA |OPENSSH |EC |DSA |PGP )?PRIVATE KEY(?: BLOCK)?-----[\s\S]*\z/,
17
+ jwt: /\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/
18
+ }.freeze
19
+
20
+ module_function
21
+
22
+ def redact(value)
23
+ output = value.to_s.dup.force_encoding(Encoding::UTF_8).scrub("?")
24
+ PATTERNS.each do |name, pattern|
25
+ output.gsub!(pattern, "[REDACTED:#{name}]")
26
+ end
27
+ output
28
+ end
29
+
30
+ def diagnostic(value, bytes: AgentCliRuntime::DIAGNOSTIC_BYTES)
31
+ message = value.respond_to?(:message) ? value.message : value.to_s
32
+ redacted = redact(message)
33
+ return nil if redacted.empty?
34
+ return redacted.freeze if redacted.bytesize <= bytes
35
+
36
+ redacted.byteslice(0, bytes).to_s
37
+ .force_encoding(Encoding::UTF_8)
38
+ .scrub("?")
39
+ .freeze
40
+ end
41
+ end
42
+ end