claude-agent-sdk 1.2.0 → 1.3.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.
@@ -3,6 +3,7 @@
3
3
  require 'json'
4
4
  require_relative 'errors'
5
5
  require_relative 'types'
6
+ require_relative 'option_forms'
6
7
 
7
8
  module ClaudeAgentSDK
8
9
  # Builds the CLI argv array from a ClaudeAgentOptions instance.
@@ -68,57 +69,34 @@ module ClaudeAgentSDK
68
69
 
69
70
  private
70
71
 
72
+ # What the option stands for, typed or as a Hash, is OptionForms' to say;
73
+ # a kind that is none of these (a preset without an append, a Hash the
74
+ # SDK does not recognize, ...) sends no prompt flag at all.
71
75
  def append_system_prompt(cmd)
72
- case @options.system_prompt
73
- when nil
76
+ prompt = OptionForms.system_prompt(@options.system_prompt)
77
+ case prompt.kind
78
+ when :empty
74
79
  # When nil, pass empty string to ensure predictable behavior without default Claude Code system prompt
75
80
  cmd.push('--system-prompt', '')
76
- when String
77
- cmd.push('--system-prompt', @options.system_prompt)
78
- when SystemPromptFile
79
- cmd.push('--system-prompt-file', path_string(@options.system_prompt.path))
80
- when SystemPromptCustom
81
- # The object form of a String prompt; snapshot travels on the
81
+ when :text
82
+ # A String, or its object form (custom); snapshot travels on the
82
83
  # initialize request, not as a CLI flag.
83
- cmd.push('--system-prompt', custom_prompt_text(@options.system_prompt.prompt))
84
- when SystemPromptPreset
84
+ cmd.push('--system-prompt', custom_prompt_text(prompt.value))
85
+ when :file
86
+ cmd.push('--system-prompt-file', path_string(prompt.value))
87
+ when :append
85
88
  # Preset activates the default Claude Code system prompt by not passing --system-prompt ""
86
89
  # Only --append-system-prompt is passed if append text is provided
87
- cmd.push('--append-system-prompt', @options.system_prompt.append) if @options.system_prompt.append
88
- when Hash
89
- append_hash_system_prompt(cmd, @options.system_prompt)
90
+ cmd.push('--append-system-prompt', prompt.value)
90
91
  end
91
92
  end
92
93
 
93
- # The type tag of a Hash option, as a String: `type: :preset` is the
94
- # natural Ruby spelling of `type: 'preset'`, and thinking and the MCP
95
- # server configs already read it that way. A missing tag is '', which
96
- # matches no branch, like any other tag the SDK does not know.
97
- def hash_type(hash)
98
- (hash[:type] || hash['type']).to_s
99
- end
100
-
101
94
  # A path as the String the command line takes. A Pathname (anything that
102
95
  # answers #to_path) is converted; every other value is returned as it is.
103
96
  def path_string(path)
104
97
  path.respond_to?(:to_path) ? path.to_path : path
105
98
  end
106
99
 
107
- def append_hash_system_prompt(cmd, prompt_hash)
108
- case hash_type(prompt_hash)
109
- when 'file'
110
- prompt_path = prompt_hash[:path] || prompt_hash['path']
111
- cmd.push('--system-prompt-file', path_string(prompt_path)) if prompt_path
112
- when 'custom'
113
- prompt = prompt_hash.fetch(:prompt) { prompt_hash['prompt'] }
114
- cmd.push('--system-prompt', custom_prompt_text(prompt))
115
- when 'preset'
116
- append = prompt_hash[:append] || prompt_hash['append']
117
- # Preset activates the default Claude Code system prompt by not passing --system-prompt ""
118
- cmd.push('--append-system-prompt', append) if append
119
- end
120
- end
121
-
122
100
  # A custom prompt is always forwarded, even when empty (an empty String
123
101
  # suppresses the default Claude Code prompt, exactly like a nil
124
102
  # system_prompt). A missing prompt is rejected loudly rather than
@@ -338,7 +316,7 @@ module ClaudeAgentSDK
338
316
  # read from JSON spell that key as a String; left next to the Symbol
339
317
  # key below it would be written twice (json 3.x raises on that).
340
318
  settings_hash = settings_hash.reject { |key, _| key.to_s == 'sandbox' }
341
- settings_hash[:sandbox] = sandbox_section(@options.sandbox)
319
+ settings_hash[:sandbox] = OptionForms.sandbox(@options.sandbox)
342
320
  end
343
321
 
344
322
  cmd.push('--settings', JSON.generate(settings_hash)) if !settings_is_path && !settings_hash.empty?
@@ -355,29 +333,10 @@ module ClaudeAgentSDK
355
333
  [{}, true]
356
334
  end
357
335
 
358
- # The sandbox section as the CLI reads it. A Hash stands for the
359
- # SandboxSettings with the same fields: the CLI only knows the camelCase
360
- # keys that class writes, and it ignores the others without an error, so
361
- # a Hash in Ruby spelling (deny_read, denied_domains) is renamed like the
362
- # typed value would be (SandboxKeys). Booleans go out as they are.
363
- def sandbox_section(sandbox)
364
- case sandbox
365
- when SandboxSettings then sandbox.to_h
366
- when Hash then SandboxKeys.normalize(sandbox)
367
- else sandbox
368
- end
369
- end
370
-
371
336
  def append_budget(cmd)
372
337
  cmd.push('--max-budget-usd', @options.max_budget_usd.to_s) if @options.max_budget_usd
373
338
 
374
- return unless @options.task_budget
375
-
376
- total = if @options.task_budget.is_a?(TaskBudget)
377
- @options.task_budget.total
378
- else
379
- @options.task_budget[:total] || @options.task_budget['total']
380
- end
339
+ total = OptionForms.task_budget_total(@options.task_budget)
381
340
  cmd.push('--task-budget', total.to_s) if total
382
341
  end
383
342
 
@@ -389,16 +348,16 @@ module ClaudeAgentSDK
389
348
  # max_thinking_tokens fallback.
390
349
  def append_thinking(cmd)
391
350
  if @options.thinking
392
- type, budget, display = thinking_fields(@options.thinking)
393
- case type
351
+ thinking = OptionForms.thinking(@options.thinking)
352
+ case thinking.type
394
353
  when 'adaptive'
395
354
  cmd.push('--thinking', 'adaptive')
396
- append_thinking_display(cmd, display)
355
+ append_thinking_display(cmd, thinking.display)
397
356
  when 'enabled'
398
- raise ArgumentError, "thinking type 'enabled' requires budget_tokens" if budget.nil?
357
+ raise ArgumentError, "thinking type 'enabled' requires budget_tokens" if thinking.budget_tokens.nil?
399
358
 
400
- cmd.push('--max-thinking-tokens', budget.to_s)
401
- append_thinking_display(cmd, display)
359
+ cmd.push('--max-thinking-tokens', thinking.budget_tokens.to_s)
360
+ append_thinking_display(cmd, thinking.display)
402
361
  when 'disabled'
403
362
  cmd.push('--thinking', 'disabled')
404
363
  else
@@ -409,20 +368,6 @@ module ClaudeAgentSDK
409
368
  end
410
369
  end
411
370
 
412
- # Explicit class dispatch — never respond_to? probes (Kernel#display
413
- # exists on every object and PRINTS the receiver to $stdout).
414
- def thinking_fields(thinking)
415
- case thinking
416
- when Hash
417
- type = (thinking[:type] || thinking['type'])&.to_s
418
- [type, thinking[:budget_tokens] || thinking['budget_tokens'], thinking[:display] || thinking['display']]
419
- when ThinkingConfigAdaptive then [thinking.type, nil, thinking.display]
420
- when ThinkingConfigEnabled then [thinking.type, thinking.budget_tokens, thinking.display]
421
- when ThinkingConfigDisabled then [thinking.type, nil, nil]
422
- else [nil, nil, nil] # falls into append_thinking's else -> ArgumentError
423
- end
424
- end
425
-
426
371
  # `--thinking-display` toggles between `"summarized"` (visible thinking
427
372
  # text) and `"omitted"` (empty thinking, signature only). Current models default
428
373
  # to `"omitted"`, so pass `display: "summarized"` to see reasoning.
@@ -448,28 +393,27 @@ module ClaudeAgentSDK
448
393
  def append_tools(cmd)
449
394
  return if @options.tools.nil?
450
395
 
451
- case @options.tools
396
+ # The preset, typed or as a Hash, is OptionForms' to recognize; every
397
+ # other value comes back as it was given and is only encoded here.
398
+ tools = OptionForms.tools(@options.tools)
399
+ case tools
400
+ when OptionForms::DEFAULT_TOOLS
401
+ cmd.push('--tools', 'default')
452
402
  when Array
453
- tools_value = @options.tools.empty? ? '' : @options.tools.join(',')
403
+ tools_value = tools.empty? ? '' : tools.join(',')
454
404
  cmd.push('--tools', tools_value)
455
405
  when String
456
406
  # The CLI's own syntax ("Read,Grep", "default", ""): passed as written.
457
- cmd.push('--tools', @options.tools)
458
- when ToolsPreset
459
- cmd.push('--tools', 'default')
407
+ cmd.push('--tools', tools)
460
408
  when Hash
461
- if hash_type(@options.tools) == 'preset'
462
- cmd.push('--tools', 'default')
463
- else
464
- cmd.push('--tools', JSON.generate(@options.tools))
465
- end
409
+ cmd.push('--tools', JSON.generate(tools))
466
410
  end
467
411
  end
468
412
 
469
413
  def append_output_format(cmd)
470
414
  return unless @options.output_format
471
415
 
472
- schema = output_schema(@options.output_format)
416
+ schema = OptionForms.output_schema(@options.output_format)
473
417
  # A json_schema output_format with a nil/absent schema must skip the
474
418
  # flag — `--json-schema null` is rejected by the CLI (Python guards
475
419
  # `schema is not None`).
@@ -479,22 +423,6 @@ module ClaudeAgentSDK
479
423
  cmd.push('--json-schema', schema_json)
480
424
  end
481
425
 
482
- # The schema of a { type: 'json_schema', schema: ... } output format; any
483
- # other value is the schema itself. The tag may be a Symbol, and `schema`
484
- # is read under the key style `type` was written in, or under the other
485
- # one when that key is absent ({ 'type' => 'json_schema', schema: {...} }).
486
- def output_schema(format)
487
- return format unless format.is_a?(Hash)
488
-
489
- if format[:type].to_s == 'json_schema'
490
- format.fetch(:schema) { format['schema'] }
491
- elsif format['type'].to_s == 'json_schema'
492
- format.fetch('schema') { format[:schema] }
493
- else
494
- format
495
- end
496
- end
497
-
498
426
  def append_additional_dirs(cmd)
499
427
  (@options.add_dirs || []).each { |dir| cmd.push('--add-dir', dir.to_s) }
500
428
  end
@@ -502,25 +430,15 @@ module ClaudeAgentSDK
502
430
  def append_mcp_servers(cmd)
503
431
  return unless @options.mcp_servers && !@options.mcp_servers.empty?
504
432
 
505
- if @options.mcp_servers.is_a?(Hash)
506
- servers_for_cli = {}
507
- @options.mcp_servers.each do |name, config|
508
- # Typed Mcp*ServerConfig objects serialize via their wire hash —
509
- # without this they'd JSON-stringify as "#<...>" via to_s.
510
- config = config.to_h if config.is_a?(Type)
511
- # Same recognition rule as ClaudeAgentSDK.extract_sdk_mcp_servers:
512
- # either key style (and a Symbol :sdk type). The live instance is
513
- # never serialized — JSON.generate would raise on it or leak its
514
- # #to_s onto the command line.
515
- servers_for_cli[name] = if config.is_a?(Hash) && (config[:type] || config['type']).to_s == 'sdk'
516
- config.except(:instance, 'instance')
517
- else
518
- config
519
- end
520
- end
521
- cmd.push('--mcp-config', JSON.generate({ mcpServers: servers_for_cli })) unless servers_for_cli.empty?
433
+ # A Hash of servers comes back with typed configs as their wire Hashes
434
+ # and without the live instance of an SDK server, which is never
435
+ # serialized: JSON.generate would raise on it or leak its #to_s onto
436
+ # the command line. Any other value is a path or JSON text.
437
+ servers = OptionForms.mcp_servers(@options.mcp_servers)
438
+ if servers.is_a?(Hash)
439
+ cmd.push('--mcp-config', JSON.generate({ mcpServers: servers })) unless servers.empty?
522
440
  else
523
- cmd.push('--mcp-config', @options.mcp_servers.to_s)
441
+ cmd.push('--mcp-config', servers.to_s)
524
442
  end
525
443
  end
526
444
 
@@ -539,17 +457,17 @@ module ClaudeAgentSDK
539
457
  def append_plugins(cmd)
540
458
  return unless @options.plugins && !@options.plugins.empty?
541
459
 
542
- @options.plugins.each do |plugin|
543
- plugin_config = plugin.is_a?(SdkPluginConfig) ? plugin.to_h : plugin
544
- plugin_path = plugin_config[:path] || plugin_config['path']
460
+ @options.plugins.each do |entry|
461
+ plugin = OptionForms.plugin(entry)
545
462
 
546
- unless %w[local plugin].include?(hash_type(plugin_config))
547
- plugin_type = plugin_config[:type] || plugin_config['type']
548
- raise ArgumentError, "Unsupported plugin type: #{plugin_type.inspect}"
463
+ unless %w[local plugin].include?(plugin.type_tag)
464
+ # raw_type looks the type up again, as it was written; asked for
465
+ # only here, so an accepted plugin has its type read once.
466
+ raise ArgumentError, "Unsupported plugin type: #{plugin.raw_type.inspect}"
549
467
  end
550
- next unless plugin_path
468
+ next unless plugin.path
551
469
 
552
- cmd.push('--plugin-dir', path_string(plugin_path))
470
+ cmd.push('--plugin-dir', path_string(plugin.path))
553
471
  end
554
472
  end
555
473
 
@@ -0,0 +1,379 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'types'
4
+
5
+ module ClaudeAgentSDK
6
+ # How the SDK reads a Hash that stands for an option value, behind one set
7
+ # of functions. Several options take a typed value "or the equivalent
8
+ # Hash"; each function here reads one such option in both forms and answers
9
+ # what its caller needs: a small frozen record (Prompt, Thinking, Plugin),
10
+ # a plain value, the value as it was given, or a new Hash made from it.
11
+ # Only the records are frozen, never a value the caller owns.
12
+ #
13
+ # The functions are stateless. They keep nothing, they do not change the
14
+ # value they are given, and they check nothing: a caller that raises on a
15
+ # bad value (CommandBuilder, for a custom prompt without text, an enabled
16
+ # thinking config without a budget, an unknown thinking or plugin type)
17
+ # still does so itself, from what a function answers. The one exception is
18
+ # .agent_definition, which builds a Hash through AgentDefinition.new, so a
19
+ # misspelled key raises there. Flag names, path conversion and JSON
20
+ # encoding stay with the caller as well.
21
+ #
22
+ # The readers were written one at a time, and each option kept the key rule
23
+ # its reader had. They are not one rule, on purpose: a Hash that carries
24
+ # both spellings of a key, or nil / false under one of them, reads as it
25
+ # always did. The rules are
26
+ #
27
+ # truthy hash[:key] || hash['key']: a nil or false Symbol key falls
28
+ # back to the String key
29
+ # presence hash.fetch(:key) { hash['key'] }: a Symbol key that is there
30
+ # is the one read, whatever it holds
31
+ #
32
+ # and HashForm, which holds the field lookups that used to sit in the
33
+ # consumers of these options, says for each key which of them applies.
34
+ # Two kinds of Hash are not read there but handed whole to the code that
35
+ # read them before, with rules of its own: a sandbox Hash to
36
+ # SandboxKeys.normalize (types/option_values.rb), by .sandbox, and an agent
37
+ # Hash to AgentDefinition.new and the Type attribute machinery, by
38
+ # .agent_definition. spec/unit/option_forms_characterization_spec.rb pins
39
+ # the rules at the command line and the initialize request; a change of
40
+ # rule is a change of behaviour, not a cleanup.
41
+ #
42
+ # @api private
43
+ module OptionForms
44
+ # What .system_prompt answers. +kind+ says what the command line gets:
45
+ #
46
+ # :empty --system-prompt '' (the option is nil)
47
+ # :none no prompt flag at all (see .system_prompt)
48
+ # :text --system-prompt value (value is whatever the custom
49
+ # form holds; the caller raises
50
+ # unless it is a String)
51
+ # :file --system-prompt-file value
52
+ # :append --append-system-prompt value
53
+ class Prompt
54
+ attr_reader :kind, :value
55
+
56
+ def initialize(kind, value = nil)
57
+ @kind = kind
58
+ @value = value
59
+ freeze
60
+ end
61
+
62
+ # +kind+ when there is a value to send, no prompt flag otherwise: a
63
+ # preset activates the default Claude Code prompt by sending no
64
+ # --system-prompt, with its append text only when it has any, and a
65
+ # file Hash names a file only when it has a path.
66
+ def self.optional(kind, value)
67
+ value ? new(kind, value) : new(:none)
68
+ end
69
+ end
70
+
71
+ # What .thinking answers: the fields of a thinking config, typed or Hash.
72
+ # +type+ is nil for a value that is no thinking config.
73
+ class Thinking
74
+ attr_reader :type, :budget_tokens, :display
75
+
76
+ def initialize(type: nil, budget_tokens: nil, display: nil)
77
+ @type = type
78
+ @budget_tokens = budget_tokens
79
+ @display = display
80
+ freeze
81
+ end
82
+ end
83
+
84
+ # What .plugin answers. +type_tag+ is the type as a String, to compare.
85
+ class Plugin
86
+ attr_reader :type_tag, :path
87
+
88
+ def initialize(type_tag:, path:, config:)
89
+ @type_tag = type_tag
90
+ @path = path
91
+ @config = config
92
+ freeze
93
+ end
94
+
95
+ # The type as it was written, for the message of the caller that
96
+ # refuses the plugin. Read from the entry anew each time it is asked
97
+ # for, and not when the record is made: the reader this replaces looked
98
+ # the type up a second time for its message, and only once it had
99
+ # refused the plugin, which a Hash that computes its values can tell.
100
+ def raw_type
101
+ HashForm.plugin_type(@config)
102
+ end
103
+ end
104
+
105
+ # The direct field lookups on an option Hash that were moved out of its
106
+ # consumers (CommandBuilder, the root extractors, the transport's sandbox
107
+ # warning), each under the rule its option has always had (truthy or
108
+ # presence, see OptionForms). The functions of OptionForms tell the forms
109
+ # of an option apart by class and come here for the Hash one.
110
+ #
111
+ # This is not every read of an option Hash in the SDK. The fields of a
112
+ # sandbox Hash are read and renamed by SandboxKeys.normalize
113
+ # (types/option_values.rb), which OptionForms.sandbox delegates to; only
114
+ # the `enabled` of the warning predicate is looked up here. The
115
+ # attributes of an agent Hash are assigned by the strict
116
+ # AgentDefinition.new, through the Type machinery, which
117
+ # OptionForms.agent_definition delegates to.
118
+ module HashForm
119
+ # The type tag, as a String: `type: :preset` is the natural Ruby
120
+ # spelling of `type: 'preset'`. Truthy, so a nil Symbol-keyed tag falls
121
+ # back to the String-keyed one. A missing tag is '', which is no tag
122
+ # the SDK knows.
123
+ def self.tag(hash)
124
+ (hash[:type] || hash['type']).to_s
125
+ end
126
+
127
+ # system_prompt, by its tag: +path+ and +append+ are truthy, +prompt+
128
+ # is by presence (a Symbol key holding nil is the prompt, and the
129
+ # caller refuses it). Any other tag, or none, is no prompt flag.
130
+ def self.system_prompt(hash)
131
+ case tag(hash)
132
+ when 'file' then Prompt.optional(:file, hash[:path] || hash['path'])
133
+ when 'custom' then Prompt.new(:text, hash.fetch(:prompt) { hash['prompt'] })
134
+ when 'preset' then Prompt.optional(:append, hash[:append] || hash['append'])
135
+ else Prompt.new(:none)
136
+ end
137
+ end
138
+
139
+ # +snapshot+ of a preset or custom system prompt, by presence.
140
+ def self.snapshot(hash)
141
+ hash.fetch(:snapshot) { hash['snapshot'] } if %w[preset custom].include?(tag(hash))
142
+ end
143
+
144
+ # +exclude_dynamic_sections+ of a preset system prompt, by presence.
145
+ def self.exclude_dynamic_sections(hash)
146
+ hash.fetch(:exclude_dynamic_sections) { hash['exclude_dynamic_sections'] } if tag(hash) == 'preset'
147
+ end
148
+
149
+ # thinking: all three fields are truthy, so `budget_tokens: false` is
150
+ # no budget. The type stays nil when there is none; +display+ is not
151
+ # checked the way the typed classes check theirs.
152
+ def self.thinking(hash)
153
+ Thinking.new(type: (hash[:type] || hash['type'])&.to_s,
154
+ budget_tokens: hash[:budget_tokens] || hash['budget_tokens'],
155
+ display: hash[:display] || hash['display'])
156
+ end
157
+
158
+ # output_format: the Symbol-keyed tag is looked at before the
159
+ # String-keyed one, and +schema+ is read by presence under the key
160
+ # style the matching tag was written in, or under the other one when
161
+ # that key is absent ({ 'type' => 'json_schema', schema: {...} }). A
162
+ # Hash that is not tagged json_schema is the schema itself.
163
+ def self.output_schema(hash)
164
+ if hash[:type].to_s == 'json_schema'
165
+ hash.fetch(:schema) { hash['schema'] }
166
+ elsif hash['type'].to_s == 'json_schema'
167
+ hash.fetch('schema') { hash[:schema] }
168
+ else
169
+ hash
170
+ end
171
+ end
172
+
173
+ # task_budget: +total+ is truthy.
174
+ def self.total(hash)
175
+ hash[:total] || hash['total']
176
+ end
177
+
178
+ # One plugin: +path+ is truthy and read first, then the tag, once.
179
+ def self.plugin(hash)
180
+ path = hash[:path] || hash['path']
181
+ Plugin.new(type_tag: tag(hash), path: path, config: hash)
182
+ end
183
+
184
+ # The type of a plugin as it was written (truthy), for Plugin#raw_type.
185
+ def self.plugin_type(hash)
186
+ hash[:type] || hash['type']
187
+ end
188
+
189
+ # sandbox, for the warning alone (.sandbox_requested?): either key
190
+ # being true will do, whatever the other one holds.
191
+ def self.enabled?(hash)
192
+ hash[:enabled] == true || hash['enabled'] == true
193
+ end
194
+
195
+ # The live server of an sdk MCP server config, by presence.
196
+ def self.instance(hash)
197
+ hash.key?(:instance) ? hash[:instance] : hash['instance']
198
+ end
199
+ end
200
+ private_constant :HashForm
201
+
202
+ # What .tools answers for the preset. An object of its own rather than a
203
+ # Symbol: a caller can write `tools: :default`, which is none of the forms
204
+ # of the option and sends no flag.
205
+ #
206
+ # @api private
207
+ DEFAULT_TOOLS = Object.new.freeze
208
+
209
+ # The system prompt as the command line takes it (see Prompt).
210
+ #
211
+ # nil is the empty prompt, and it is the only value that is. No prompt
212
+ # flag at all, so that the CLI runs with its default prompt, is the answer
213
+ # for a preset without an append, a file Hash without a path, a Hash with
214
+ # an unknown tag or none, and a value that is none of the forms of the
215
+ # option. A typed SystemPromptFile is a file prompt whatever its path
216
+ # holds.
217
+ def self.system_prompt(value)
218
+ case value
219
+ when nil then Prompt.new(:empty)
220
+ when String then Prompt.new(:text, value)
221
+ when SystemPromptFile then Prompt.new(:file, value.path)
222
+ when SystemPromptCustom then Prompt.new(:text, value.prompt)
223
+ when SystemPromptPreset then Prompt.optional(:append, value.append)
224
+ when Hash then HashForm.system_prompt(value)
225
+ else Prompt.new(:none)
226
+ end
227
+ end
228
+
229
+ # The +snapshot+ of a preset or custom system prompt, for the initialize
230
+ # request: true, false, or nil when there is none to send. Only a genuine
231
+ # true or false is answered (`snapshot: false` is the value callers set).
232
+ # The prompt text is not looked at: a session over a transport that
233
+ # builds no command line still gets its snapshot through.
234
+ def self.system_prompt_snapshot(value)
235
+ case value
236
+ when SystemPromptPreset, SystemPromptCustom then boolean(value.snapshot)
237
+ when Hash then boolean(HashForm.snapshot(value))
238
+ end
239
+ end
240
+
241
+ # The +exclude_dynamic_sections+ of a preset system prompt, for the
242
+ # initialize request: true, false, or nil when there is none to send.
243
+ def self.exclude_dynamic_sections(value)
244
+ case value
245
+ when SystemPromptPreset then boolean(value.exclude_dynamic_sections)
246
+ when Hash then boolean(HashForm.exclude_dynamic_sections(value))
247
+ end
248
+ end
249
+
250
+ # The fields of a thinking config (see Thinking). The typed classes are
251
+ # told apart by class, never by respond_to?: Kernel#display exists on
252
+ # every object and prints the receiver to $stdout.
253
+ def self.thinking(value)
254
+ case value
255
+ when Hash then HashForm.thinking(value)
256
+ when ThinkingConfigAdaptive then Thinking.new(type: value.type, display: value.display)
257
+ when ThinkingConfigEnabled
258
+ Thinking.new(type: value.type, budget_tokens: value.budget_tokens, display: value.display)
259
+ when ThinkingConfigDisabled then Thinking.new(type: value.type)
260
+ else Thinking.new
261
+ end
262
+ end
263
+
264
+ # DEFAULT_TOOLS for the tools preset, typed or as a Hash tagged 'preset';
265
+ # any other value as it was given (an Array of names, the CLI's own
266
+ # String syntax, a Hash the caller sends as JSON text).
267
+ def self.tools(value)
268
+ case value
269
+ when ToolsPreset then DEFAULT_TOOLS
270
+ when Hash then HashForm.tag(value) == 'preset' ? DEFAULT_TOOLS : value
271
+ else value
272
+ end
273
+ end
274
+
275
+ # The schema of a { type: 'json_schema', schema: ... } output format; any
276
+ # other value is the schema itself. The value found is answered as it
277
+ # is, false included; only the caller leaves out nil.
278
+ def self.output_schema(value)
279
+ value.is_a?(Hash) ? HashForm.output_schema(value) : value
280
+ end
281
+
282
+ # The total of a task budget, or nil when there is none. Any value that
283
+ # is not a TaskBudget is read as a Hash, which is how one that is no
284
+ # budget at all still fails loudly.
285
+ def self.task_budget_total(value)
286
+ return unless value
287
+ return value.total if value.is_a?(TaskBudget)
288
+
289
+ HashForm.total(value)
290
+ end
291
+
292
+ # One entry of +plugins+ (see Plugin); a typed SdkPluginConfig is read
293
+ # through its #to_h, taken once. The caller refuses a type that is no
294
+ # plugin type, asking for Plugin#raw_type only then, and skips an entry
295
+ # without a path.
296
+ def self.plugin(value)
297
+ HashForm.plugin(value.is_a?(SdkPluginConfig) ? value.to_h : value)
298
+ end
299
+
300
+ # The sandbox section as the CLI reads it. A Hash stands for the
301
+ # SandboxSettings with the same fields: the CLI only knows the camelCase
302
+ # keys that class writes, and it ignores the others without an error, so
303
+ # a Hash in Ruby spelling (deny_read, denied_domains) is renamed like the
304
+ # typed value would be (SandboxKeys, which has key rules of its own).
305
+ # Every other value, booleans and nil included, is answered as it is.
306
+ def self.sandbox(value)
307
+ case value
308
+ when SandboxSettings then value.to_h
309
+ when Hash then SandboxKeys.normalize(value)
310
+ else value
311
+ end
312
+ end
313
+
314
+ # True when the option enables the sandbox: `true`, a SandboxSettings
315
+ # with +enabled+ true, or a Hash that says so under either key, which is
316
+ # not what .sandbox sends for a Hash carrying both. Deliberately a
317
+ # shallow read and nothing else: the transport asks from its stderr
318
+ # threads, where walking the Hash or calling a #to_h a user can override
319
+ # has no place.
320
+ def self.sandbox_requested?(value)
321
+ case value
322
+ when SandboxSettings then value.enabled == true
323
+ when Hash then HashForm.enabled?(value)
324
+ else value == true
325
+ end
326
+ end
327
+
328
+ # The MCP servers as the command line takes them. A Hash of servers is
329
+ # answered as a new Hash: a typed Mcp*ServerConfig as its wire Hash
330
+ # (as JSON it would otherwise read "#<...>"), and an sdk entry
331
+ # (.sdk_server?) without its +instance+, under either key: the live
332
+ # server is never serialized. Any other value (the path of a config
333
+ # file, JSON text) is answered as it was given.
334
+ def self.mcp_servers(value)
335
+ return value unless value.is_a?(Hash)
336
+
337
+ servers = {}
338
+ value.each do |name, config|
339
+ config = config.to_h if config.is_a?(Type)
340
+ servers[name] = sdk_server?(config) ? config.except(:instance, 'instance') : config
341
+ end
342
+ servers
343
+ end
344
+
345
+ # The live SDK MCP servers of an +mcp_servers+ Hash, by server name: the
346
+ # +instance+ of every sdk entry, typed or Hash. The entries are
347
+ # recognized exactly as .mcp_servers recognizes them, which strips the
348
+ # instance from the same ones. Empty for any other value.
349
+ def self.sdk_mcp_servers(value)
350
+ return {} unless value.is_a?(Hash)
351
+
352
+ servers = {}
353
+ value.each do |name, config|
354
+ config = config.to_h if config.is_a?(Type)
355
+ servers[name] = HashForm.instance(config) if sdk_server?(config)
356
+ end
357
+ servers
358
+ end
359
+
360
+ # One value of +agents+ as an AgentDefinition. A Hash stands for the
361
+ # AgentDefinition with the same attributes and is built through .new:
362
+ # the user wrote it, so a misspelled key raises as on the typed class.
363
+ def self.agent_definition(value)
364
+ value.is_a?(Hash) ? AgentDefinition.new(value) : value
365
+ end
366
+
367
+ # An MCP server config (a typed one already as its wire Hash) that is an
368
+ # in-process SDK server: a Hash tagged 'sdk'.
369
+ def self.sdk_server?(config)
370
+ config.is_a?(Hash) && HashForm.tag(config) == 'sdk'
371
+ end
372
+
373
+ def self.boolean(value)
374
+ value if [true, false].include?(value)
375
+ end
376
+
377
+ private_class_method :sdk_server?, :boolean
378
+ end
379
+ end