claude-agent-sdk 0.36.0 → 0.37.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +2 -2
  4. data/docs/client.md +26 -1
  5. data/docs/hooks-and-permissions.md +5 -3
  6. data/docs/mcp-servers.md +1 -2
  7. data/docs/sessions.md +81 -2
  8. data/docs/types.md +106 -4
  9. data/lib/claude_agent_sdk/cli_installer.rb +30 -3
  10. data/lib/claude_agent_sdk/command_builder.rb +109 -98
  11. data/lib/claude_agent_sdk/deprecation.rb +39 -0
  12. data/lib/claude_agent_sdk/fiber_boundary.rb +2 -0
  13. data/lib/claude_agent_sdk/instrumentation/otel.rb +15 -7
  14. data/lib/claude_agent_sdk/message_parser.rb +23 -9
  15. data/lib/claude_agent_sdk/observer.rb +2 -1
  16. data/lib/claude_agent_sdk/option_warnings.rb +2 -0
  17. data/lib/claude_agent_sdk/query.rb +50 -43
  18. data/lib/claude_agent_sdk/sdk_mcp_server.rb +22 -13
  19. data/lib/claude_agent_sdk/session_mutations.rb +20 -8
  20. data/lib/claude_agent_sdk/session_resume.rb +20 -11
  21. data/lib/claude_agent_sdk/session_store.rb +7 -3
  22. data/lib/claude_agent_sdk/session_summary.rb +4 -2
  23. data/lib/claude_agent_sdk/sessions.rb +8 -6
  24. data/lib/claude_agent_sdk/streaming.rb +1 -1
  25. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +72 -40
  26. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +14 -10
  27. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +2 -0
  28. data/lib/claude_agent_sdk/types/attributes.rb +271 -0
  29. data/lib/claude_agent_sdk/types/base.rb +320 -0
  30. data/lib/claude_agent_sdk/types/content_blocks.rb +57 -0
  31. data/lib/claude_agent_sdk/types/hooks.rb +640 -0
  32. data/lib/claude_agent_sdk/types/mcp.rb +232 -0
  33. data/lib/claude_agent_sdk/types/messages.rb +614 -0
  34. data/lib/claude_agent_sdk/types/option_values.rb +302 -0
  35. data/lib/claude_agent_sdk/types/options.rb +352 -0
  36. data/lib/claude_agent_sdk/types/permissions.rb +107 -0
  37. data/lib/claude_agent_sdk/types/sessions.rb +10 -0
  38. data/lib/claude_agent_sdk/types.rb +13 -2534
  39. data/lib/claude_agent_sdk/version.rb +1 -1
  40. data/lib/claude_agent_sdk.rb +51 -17
  41. metadata +11 -1
@@ -1,22 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require_relative "errors"
5
- require_relative "types"
3
+ require 'json'
4
+ require_relative 'errors'
5
+ require_relative 'types'
6
6
 
7
7
  module ClaudeAgentSDK
8
8
  # Builds the CLI argv array from a ClaudeAgentOptions instance.
9
- class CommandBuilder
9
+ class CommandBuilder # rubocop:disable Metrics/ClassLength -- one append_* method per CLI flag group
10
+ # @api private
10
11
  EXTRA_ARG_FLAG_REGEXP = /\A[a-z0-9][a-z0-9-]*\z/
11
12
 
12
13
  # Parentheses and commas are delimiters to the --allowedTools tokenizer;
13
14
  # control characters (C0, DEL, C1) never appear in a skill directory name.
14
15
  # U+FEFF is here rather than in the whitespace checks below because the
15
16
  # CLI trims it as whitespace and [[:space:]] does not match it.
17
+ #
18
+ # @api private
16
19
  SKILL_NAME_INVALID_CHARS = /[(),\u0000-\u001F\u007F-\u009F\uFEFF]/
17
20
 
18
21
  # Unicode-aware edge whitespace: the CLI and Skill tool trim Unicode
19
22
  # whitespace, and Ruby's String#strip is ASCII-only.
23
+ #
24
+ # @api private
20
25
  SKILL_NAME_EDGE_WHITESPACE = /\A[[:space:]]+|[[:space:]]+\z/
21
26
 
22
27
  def initialize(cli_path, options)
@@ -25,7 +30,7 @@ module ClaudeAgentSDK
25
30
  end
26
31
 
27
32
  def build
28
- cmd = [@cli_path, "--output-format", "stream-json", "--verbose"]
33
+ cmd = [@cli_path, '--output-format', 'stream-json', '--verbose']
29
34
 
30
35
  # skills auto-wires the Skill tool into --allowedTools and defaults
31
36
  # --setting-sources; compute both once so the two flags cannot diverge
@@ -56,7 +61,7 @@ module ClaudeAgentSDK
56
61
  # Always use streaming mode for bidirectional control protocol.
57
62
  # Prompts and agents are sent via stdin (initialize + user messages),
58
63
  # which avoids OS ARG_MAX limits for large prompts and agent configurations.
59
- cmd.push("--input-format", "stream-json")
64
+ cmd.push('--input-format', 'stream-json')
60
65
 
61
66
  cmd
62
67
  end
@@ -67,37 +72,37 @@ module ClaudeAgentSDK
67
72
  case @options.system_prompt
68
73
  when nil
69
74
  # When nil, pass empty string to ensure predictable behavior without default Claude Code system prompt
70
- cmd.push("--system-prompt", "")
75
+ cmd.push('--system-prompt', '')
71
76
  when String
72
- cmd.push("--system-prompt", @options.system_prompt)
77
+ cmd.push('--system-prompt', @options.system_prompt)
73
78
  when SystemPromptFile
74
- cmd.push("--system-prompt-file", @options.system_prompt.path)
79
+ cmd.push('--system-prompt-file', @options.system_prompt.path)
75
80
  when SystemPromptCustom
76
81
  # The object form of a String prompt; snapshot travels on the
77
82
  # initialize request, not as a CLI flag.
78
- cmd.push("--system-prompt", custom_prompt_text(@options.system_prompt.prompt))
83
+ cmd.push('--system-prompt', custom_prompt_text(@options.system_prompt.prompt))
79
84
  when SystemPromptPreset
80
85
  # Preset activates the default Claude Code system prompt by not passing --system-prompt ""
81
86
  # Only --append-system-prompt is passed if append text is provided
82
- cmd.push("--append-system-prompt", @options.system_prompt.append) if @options.system_prompt.append
87
+ cmd.push('--append-system-prompt', @options.system_prompt.append) if @options.system_prompt.append
83
88
  when Hash
84
89
  append_hash_system_prompt(cmd, @options.system_prompt)
85
90
  end
86
91
  end
87
92
 
88
93
  def append_hash_system_prompt(cmd, prompt_hash)
89
- prompt_type = prompt_hash[:type] || prompt_hash["type"]
94
+ prompt_type = prompt_hash[:type] || prompt_hash['type']
90
95
  case prompt_type
91
- when "file"
92
- prompt_path = prompt_hash[:path] || prompt_hash["path"]
93
- cmd.push("--system-prompt-file", prompt_path) if prompt_path
94
- when "custom"
95
- prompt = prompt_hash.fetch(:prompt) { prompt_hash["prompt"] }
96
- cmd.push("--system-prompt", custom_prompt_text(prompt))
97
- when "preset"
98
- append = prompt_hash[:append] || prompt_hash["append"]
96
+ when 'file'
97
+ prompt_path = prompt_hash[:path] || prompt_hash['path']
98
+ cmd.push('--system-prompt-file', prompt_path) if prompt_path
99
+ when 'custom'
100
+ prompt = prompt_hash.fetch(:prompt) { prompt_hash['prompt'] }
101
+ cmd.push('--system-prompt', custom_prompt_text(prompt))
102
+ when 'preset'
103
+ append = prompt_hash[:append] || prompt_hash['append']
99
104
  # Preset activates the default Claude Code system prompt by not passing --system-prompt ""
100
- cmd.push("--append-system-prompt", append) if append
105
+ cmd.push('--append-system-prompt', append) if append
101
106
  end
102
107
  end
103
108
 
@@ -113,7 +118,7 @@ module ClaudeAgentSDK
113
118
  end
114
119
 
115
120
  def append_allowed_tools(cmd, allowed_tools)
116
- cmd.push("--allowedTools", allowed_tools.join(",")) unless allowed_tools.empty?
121
+ cmd.push('--allowedTools', allowed_tools.join(',')) unless allowed_tools.empty?
117
122
  end
118
123
 
119
124
  # Mirror of Python's _apply_skills_defaults: when skills are requested,
@@ -130,10 +135,10 @@ module ClaudeAgentSDK
130
135
  skills = @options.skills
131
136
  return [allowed_tools, setting_sources] if skills.nil?
132
137
 
133
- valid = skills == "all" || skills.is_a?(Array)
138
+ valid = skills == 'all' || skills.is_a?(Array)
134
139
  raise ArgumentError, "skills must be 'all' or an Array of skill names (got #{skills.inspect})" unless valid
135
140
 
136
- entries = skills == "all" ? ["Skill"] : skills.map { |name| "Skill(#{validate_skill_name(name)})" }
141
+ entries = skills == 'all' ? ['Skill'] : skills.map { |name| "Skill(#{validate_skill_name(name)})" }
137
142
  entries.each { |entry| allowed_tools << entry unless allowed_tools.include?(entry) }
138
143
  setting_sources = %w[user project] if setting_sources.nil?
139
144
  [allowed_tools, setting_sources]
@@ -151,7 +156,7 @@ module ClaudeAgentSDK
151
156
  # rejected too, so a dead rule fails loudly here instead of silently
152
157
  # granting nothing. Returns the name as a UTF-8 String so later argv
153
158
  # joins cannot raise Encoding::CompatibilityError.
154
- def validate_skill_name(name) # rubocop:disable Metrics/MethodLength
159
+ def validate_skill_name(name) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- one guard per rejected input shape, each with its own message
155
160
  raise TypeError, "Skill names must be strings, got #{name.class}: #{name.inspect}" unless name.is_a?(String)
156
161
 
157
162
  # Ruby's analogue of Python's surrogate check: a lone surrogate (or any
@@ -164,75 +169,77 @@ module ClaudeAgentSDK
164
169
  end
165
170
  if utf8.nil? || !utf8.valid_encoding?
166
171
  raise ArgumentError, "Invalid skill name #{name.inspect}: contains bytes that cannot form " \
167
- "valid UTF-8 (such as a surrogate code point), which can never match " \
168
- "a skill the CLI discovered."
172
+ 'valid UTF-8 (such as a surrogate code point), which can never match ' \
173
+ 'a skill the CLI discovered.'
169
174
  end
170
175
 
171
- stripped = utf8.gsub(SKILL_NAME_EDGE_WHITESPACE, "")
172
- raise ArgumentError, "Skill names must be non-empty strings" if stripped.empty?
176
+ stripped = utf8.gsub(SKILL_NAME_EDGE_WHITESPACE, '')
177
+ raise ArgumentError, 'Skill names must be non-empty strings' if stripped.empty?
173
178
 
174
179
  if utf8 != stripped
175
180
  raise ArgumentError, "Invalid skill name #{name.inspect}: leading or trailing whitespace " \
176
- "can never match -- the Skill tool trims the invoked name."
181
+ 'can never match -- the Skill tool trims the invoked name.'
177
182
  end
178
183
  if SKILL_NAME_INVALID_CHARS.match?(utf8)
179
184
  raise ArgumentError, "Invalid skill name #{name.inspect}: parentheses, commas, control " \
180
- "characters, and byte-order marks are not allowed. Names match the " \
185
+ 'characters, and byte-order marks are not allowed. Names match the ' \
181
186
  "skill's directory name, or 'plugin:skill' for plugin-qualified skills."
182
187
  end
183
- raise ArgumentError, "Invalid skill name '*': use skills: 'all' to enable every skill." if utf8 == "*"
188
+ raise ArgumentError, "Invalid skill name '*': use skills: 'all' to enable every skill." if utf8 == '*'
184
189
 
185
- if utf8.end_with?(":*", " *")
190
+ if utf8.end_with?(':*', ' *')
186
191
  raise ArgumentError, "Invalid skill name #{name.inspect}: wildcard-suffix names are not " \
187
- "allowed; list each skill by its exact name."
192
+ 'allowed; list each skill by its exact name.'
188
193
  end
189
- if utf8.start_with?("/")
194
+ if utf8.start_with?('/')
190
195
  raise ArgumentError, "Invalid skill name #{name.inspect}: skill names may not start with " \
191
196
  "'/'. The skills option takes the canonical name, not the " \
192
- "slash-command form."
197
+ 'slash-command form.'
193
198
  end
194
- if utf8.include?("\\\\")
199
+ if utf8.include?('\\\\')
195
200
  raise ArgumentError, "Invalid skill name #{name.inspect}: consecutive backslashes are not " \
196
- "allowed -- the per-rule parser collapses them, so the rule would " \
197
- "name a different skill."
201
+ 'allowed -- the per-rule parser collapses them, so the rule would ' \
202
+ 'name a different skill.'
198
203
  end
199
- if utf8.end_with?("\\")
204
+ if utf8.end_with?('\\')
200
205
  raise ArgumentError, "Invalid skill name #{name.inspect}: names may not end with an " \
201
- "unpaired backslash."
206
+ 'unpaired backslash.'
202
207
  end
203
208
 
204
209
  utf8
205
210
  end
206
211
 
207
212
  def append_disallowed_tools(cmd)
208
- cmd.push("--disallowedTools", @options.disallowed_tools.join(",")) unless @options.disallowed_tools.empty?
213
+ cmd.push('--disallowedTools', @options.disallowed_tools.join(',')) unless @options.disallowed_tools.empty?
209
214
  end
210
215
 
211
216
  def append_max_turns(cmd)
212
- cmd.push("--max-turns", @options.max_turns.to_s) if @options.max_turns
217
+ cmd.push('--max-turns', @options.max_turns.to_s) if @options.max_turns
213
218
  end
214
219
 
215
220
  def append_model(cmd)
216
- cmd.push("--model", @options.model) if @options.model
217
- cmd.push("--fallback-model", @options.fallback_model) if @options.fallback_model
221
+ cmd.push('--model', @options.model) if @options.model
222
+ cmd.push('--fallback-model', @options.fallback_model) if @options.fallback_model
218
223
  # Server-side advisor tool (experimental, Anthropic API only). The CLI
219
224
  # validates the main-model/advisor pairing; pairing rules are
220
225
  # CLI-version-dependent, so the SDK passes the value through verbatim.
221
- cmd.push("--advisor", @options.advisor_model) if @options.advisor_model
226
+ cmd.push('--advisor', @options.advisor_model) if @options.advisor_model
222
227
  end
223
228
 
224
229
  def append_permission(cmd)
225
- cmd.push("--permission-prompt-tool", @options.permission_prompt_tool_name) if @options.permission_prompt_tool_name
226
- cmd.push("--permission-mode", @options.permission_mode) if @options.permission_mode
230
+ cmd.push('--permission-prompt-tool', @options.permission_prompt_tool_name) if @options.permission_prompt_tool_name
231
+ cmd.push('--permission-mode', @options.permission_mode) if @options.permission_mode
227
232
  end
228
233
 
229
234
  def append_session(cmd)
230
235
  # `--continue` and `--resume <id>` are mutually exclusive session-restore
231
236
  # modes. Passing both surfaces as a generic non-zero CLI exit, which is
232
237
  # painful to debug at the caller; raise early in the SDK stack instead.
233
- raise ArgumentError, "continue_conversation and resume are mutually exclusive" if @options.continue_conversation && @options.resume
238
+ if @options.continue_conversation && @options.resume
239
+ raise ArgumentError, 'continue_conversation and resume are mutually exclusive'
240
+ end
234
241
 
235
- cmd.push("--continue") if @options.continue_conversation
242
+ cmd.push('--continue') if @options.continue_conversation
236
243
  # =-joined single tokens: the CLI declares `--resume [value]` with an
237
244
  # OPTIONAL value, so in the two-token form a dash-leading value is not
238
245
  # bound to the flag and parses as an independent CLI flag — letting an
@@ -252,7 +259,7 @@ module ClaudeAgentSDK
252
259
  def append_resume_session_at(cmd)
253
260
  return unless @options.resume_session_at
254
261
 
255
- raise ArgumentError, "resume_session_at requires resume to be set" unless @options.resume
262
+ raise ArgumentError, 'resume_session_at requires resume to be set' unless @options.resume
256
263
 
257
264
  # Equals form for the same reason as --resume above: never let a
258
265
  # dash-leading value parse as a separate flag.
@@ -287,7 +294,7 @@ module ClaudeAgentSDK
287
294
  # must reach the CLI so it can override a sandbox enabled in the
288
295
  # settings JSON (`sandbox: true`, the boolean toggle, used to crash on
289
296
  # true.empty?; false and {} were silently dropped).
290
- def append_settings(cmd)
297
+ def append_settings(cmd) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- folds settings, sandbox and flag overrides into one --settings value
291
298
  return unless @options.settings || !@options.sandbox.nil?
292
299
 
293
300
  settings_hash = {}
@@ -298,9 +305,9 @@ module ClaudeAgentSDK
298
305
  begin
299
306
  settings_hash = JSON.parse(@options.settings)
300
307
  rescue JSON::ParserError
301
- if @options.sandbox.nil?
308
+ if @options.sandbox.nil? # rubocop:disable Metrics/BlockNesting -- settings-is-a-path fallback inside the JSON parse rescue
302
309
  settings_is_path = true
303
- cmd.push("--settings", @options.settings)
310
+ cmd.push('--settings', @options.settings)
304
311
  else
305
312
  settings_hash = load_settings_file(@options.settings)
306
313
  end
@@ -314,20 +321,20 @@ module ClaudeAgentSDK
314
321
  settings_hash[:sandbox] = @options.sandbox.is_a?(SandboxSettings) ? @options.sandbox.to_h : @options.sandbox
315
322
  end
316
323
 
317
- cmd.push("--settings", JSON.generate(settings_hash)) if !settings_is_path && !settings_hash.empty?
324
+ cmd.push('--settings', JSON.generate(settings_hash)) if !settings_is_path && !settings_hash.empty?
318
325
  end
319
326
 
320
327
  def append_budget(cmd)
321
- cmd.push("--max-budget-usd", @options.max_budget_usd.to_s) if @options.max_budget_usd
328
+ cmd.push('--max-budget-usd', @options.max_budget_usd.to_s) if @options.max_budget_usd
322
329
 
323
330
  return unless @options.task_budget
324
331
 
325
332
  total = if @options.task_budget.is_a?(TaskBudget)
326
333
  @options.task_budget.total
327
334
  else
328
- @options.task_budget[:total] || @options.task_budget["total"]
335
+ @options.task_budget[:total] || @options.task_budget['total']
329
336
  end
330
- cmd.push("--task-budget", total.to_s) if total
337
+ cmd.push('--task-budget', total.to_s) if total
331
338
  end
332
339
 
333
340
  # Thinking configuration takes precedence over deprecated
@@ -340,21 +347,21 @@ module ClaudeAgentSDK
340
347
  if @options.thinking
341
348
  type, budget, display = thinking_fields(@options.thinking)
342
349
  case type
343
- when "adaptive"
344
- cmd.push("--thinking", "adaptive")
350
+ when 'adaptive'
351
+ cmd.push('--thinking', 'adaptive')
345
352
  append_thinking_display(cmd, display)
346
- when "enabled"
353
+ when 'enabled'
347
354
  raise ArgumentError, "thinking type 'enabled' requires budget_tokens" if budget.nil?
348
355
 
349
- cmd.push("--max-thinking-tokens", budget.to_s)
356
+ cmd.push('--max-thinking-tokens', budget.to_s)
350
357
  append_thinking_display(cmd, display)
351
- when "disabled"
352
- cmd.push("--thinking", "disabled")
358
+ when 'disabled'
359
+ cmd.push('--thinking', 'disabled')
353
360
  else
354
361
  raise ArgumentError, "unsupported thinking config: #{@options.thinking.inspect}"
355
362
  end
356
363
  elsif @options.max_thinking_tokens
357
- cmd.push("--max-thinking-tokens", @options.max_thinking_tokens.to_s)
364
+ cmd.push('--max-thinking-tokens', @options.max_thinking_tokens.to_s)
358
365
  end
359
366
  end
360
367
 
@@ -363,8 +370,8 @@ module ClaudeAgentSDK
363
370
  def thinking_fields(thinking)
364
371
  case thinking
365
372
  when Hash
366
- type = (thinking[:type] || thinking["type"])&.to_s
367
- [type, thinking[:budget_tokens] || thinking["budget_tokens"], thinking[:display] || thinking["display"]]
373
+ type = (thinking[:type] || thinking['type'])&.to_s
374
+ [type, thinking[:budget_tokens] || thinking['budget_tokens'], thinking[:display] || thinking['display']]
368
375
  when ThinkingConfigAdaptive then [thinking.type, nil, thinking.display]
369
376
  when ThinkingConfigEnabled then [thinking.type, thinking.budget_tokens, thinking.display]
370
377
  when ThinkingConfigDisabled then [thinking.type, nil, nil]
@@ -378,20 +385,20 @@ module ClaudeAgentSDK
378
385
  def append_thinking_display(cmd, display)
379
386
  return if display.nil?
380
387
 
381
- cmd.push("--thinking-display", display.to_s)
388
+ cmd.push('--thinking-display', display.to_s)
382
389
  end
383
390
 
384
391
  # The set of supported levels is model-dependent; the CLI falls back to
385
392
  # the highest supported level at or below the one requested
386
393
  # (e.g. `xhigh` → `high` on Opus 4.6).
387
394
  def append_effort(cmd)
388
- cmd.push("--effort", @options.effort.to_s) if @options.effort
395
+ cmd.push('--effort', @options.effort.to_s) if @options.effort
389
396
  end
390
397
 
391
398
  def append_betas(cmd)
392
399
  return unless @options.betas && !@options.betas.empty?
393
400
 
394
- cmd.push("--betas", @options.betas.join(","))
401
+ cmd.push('--betas', @options.betas.join(','))
395
402
  end
396
403
 
397
404
  def append_tools(cmd)
@@ -399,15 +406,15 @@ module ClaudeAgentSDK
399
406
 
400
407
  case @options.tools
401
408
  when Array
402
- tools_value = @options.tools.empty? ? "" : @options.tools.join(",")
403
- cmd.push("--tools", tools_value)
409
+ tools_value = @options.tools.empty? ? '' : @options.tools.join(',')
410
+ cmd.push('--tools', tools_value)
404
411
  when ToolsPreset
405
- cmd.push("--tools", "default")
412
+ cmd.push('--tools', 'default')
406
413
  when Hash
407
- if (@options.tools[:type] || @options.tools["type"]) == "preset"
408
- cmd.push("--tools", "default")
414
+ if (@options.tools[:type] || @options.tools['type']) == 'preset'
415
+ cmd.push('--tools', 'default')
409
416
  else
410
- cmd.push("--tools", JSON.generate(@options.tools))
417
+ cmd.push('--tools', JSON.generate(@options.tools))
411
418
  end
412
419
  end
413
420
  end
@@ -415,10 +422,10 @@ module ClaudeAgentSDK
415
422
  def append_output_format(cmd)
416
423
  return unless @options.output_format
417
424
 
418
- schema = if @options.output_format.is_a?(Hash) && @options.output_format[:type] == "json_schema"
425
+ schema = if @options.output_format.is_a?(Hash) && @options.output_format[:type] == 'json_schema'
419
426
  @options.output_format[:schema]
420
- elsif @options.output_format.is_a?(Hash) && @options.output_format["type"] == "json_schema"
421
- @options.output_format["schema"]
427
+ elsif @options.output_format.is_a?(Hash) && @options.output_format['type'] == 'json_schema'
428
+ @options.output_format['schema']
422
429
  else
423
430
  @options.output_format
424
431
  end
@@ -428,11 +435,11 @@ module ClaudeAgentSDK
428
435
  return if schema.nil?
429
436
 
430
437
  schema_json = schema.is_a?(String) ? schema : JSON.generate(schema)
431
- cmd.push("--json-schema", schema_json)
438
+ cmd.push('--json-schema', schema_json)
432
439
  end
433
440
 
434
441
  def append_additional_dirs(cmd)
435
- @options.add_dirs.each { |dir| cmd.push("--add-dir", dir.to_s) }
442
+ @options.add_dirs.each { |dir| cmd.push('--add-dir', dir.to_s) }
436
443
  end
437
444
 
438
445
  def append_mcp_servers(cmd)
@@ -448,28 +455,28 @@ module ClaudeAgentSDK
448
455
  # either key style (and a Symbol :sdk type). The live instance is
449
456
  # never serialized — JSON.generate would raise on it or leak its
450
457
  # #to_s onto the command line.
451
- servers_for_cli[name] = if config.is_a?(Hash) && (config[:type] || config["type"]).to_s == "sdk"
452
- config.except(:instance, "instance")
458
+ servers_for_cli[name] = if config.is_a?(Hash) && (config[:type] || config['type']).to_s == 'sdk'
459
+ config.except(:instance, 'instance')
453
460
  else
454
461
  config
455
462
  end
456
463
  end
457
- cmd.push("--mcp-config", JSON.generate({ mcpServers: servers_for_cli })) unless servers_for_cli.empty?
464
+ cmd.push('--mcp-config', JSON.generate({ mcpServers: servers_for_cli })) unless servers_for_cli.empty?
458
465
  else
459
- cmd.push("--mcp-config", @options.mcp_servers.to_s)
466
+ cmd.push('--mcp-config', @options.mcp_servers.to_s)
460
467
  end
461
468
  end
462
469
 
463
470
  # NOTE: agents are sent via the initialize control request (not CLI args)
464
471
  # to avoid OS ARG_MAX limits with large agent configurations.
465
472
  def append_boolean_flags(cmd)
466
- cmd.push("--include-partial-messages") if @options.include_partial_messages
467
- cmd.push("--fork-session") if @options.fork_session
468
- cmd.push("--bare") if @options.bare
469
- cmd.push("--include-hook-events") if @options.include_hook_events
470
- cmd.push("--strict-mcp-config") if @options.strict_mcp_config
473
+ cmd.push('--include-partial-messages') if @options.include_partial_messages
474
+ cmd.push('--fork-session') if @options.fork_session
475
+ cmd.push('--bare') if @options.bare
476
+ cmd.push('--include-hook-events') if @options.include_hook_events
477
+ cmd.push('--strict-mcp-config') if @options.strict_mcp_config
471
478
  # When a session_store is set, ask the CLI to emit transcript_mirror frames.
472
- cmd.push("--session-mirror") if @options.session_store
479
+ cmd.push('--session-mirror') if @options.session_store
473
480
  end
474
481
 
475
482
  def append_plugins(cmd)
@@ -477,29 +484,33 @@ module ClaudeAgentSDK
477
484
 
478
485
  @options.plugins.each do |plugin|
479
486
  plugin_config = plugin.is_a?(SdkPluginConfig) ? plugin.to_h : plugin
480
- plugin_type = plugin_config[:type] || plugin_config["type"]
481
- plugin_path = plugin_config[:path] || plugin_config["path"]
487
+ plugin_type = plugin_config[:type] || plugin_config['type']
488
+ plugin_path = plugin_config[:path] || plugin_config['path']
482
489
 
483
- raise ArgumentError, "Unsupported plugin type: #{plugin_type.inspect}" unless %w[local plugin].include?(plugin_type)
490
+ unless %w[local plugin].include?(plugin_type)
491
+ raise ArgumentError, "Unsupported plugin type: #{plugin_type.inspect}"
492
+ end
484
493
  next unless plugin_path
485
494
 
486
- cmd.push("--plugin-dir", plugin_path)
495
+ cmd.push('--plugin-dir', plugin_path)
487
496
  end
488
497
  end
489
498
 
490
499
  def append_setting_sources(cmd, setting_sources)
491
500
  return if setting_sources.nil?
492
501
 
493
- cmd.push("--setting-sources", setting_sources.join(","))
502
+ cmd.push('--setting-sources', setting_sources.join(','))
494
503
  end
495
504
 
496
505
  def append_extra_args(cmd)
497
506
  @options.extra_args.each do |flag, value|
498
- raise ArgumentError, "Invalid extra_args flag name: #{flag.inspect} (expected lowercase kebab-case)" unless EXTRA_ARG_FLAG_REGEXP.match?(flag)
507
+ unless EXTRA_ARG_FLAG_REGEXP.match?(flag)
508
+ raise ArgumentError, "Invalid extra_args flag name: #{flag.inspect} (expected lowercase kebab-case)"
509
+ end
499
510
 
500
511
  if value.nil?
501
512
  cmd.push("--#{flag}")
502
- elsif value.to_s.start_with?("-")
513
+ elsif value.to_s.start_with?('-')
503
514
  # A dash-leading value must bind via `=` or the CLI parses it as a
504
515
  # separate flag — same injection class as --resume above.
505
516
  cmd.push("--#{flag}=#{value}")
@@ -42,10 +42,49 @@ module ClaudeAgentSDK
42
42
  end
43
43
  end
44
44
 
45
+ # Warn +message+ once per process per +key+, attributed to the first
46
+ # caller frame outside the SDK's lib/ directory: the user's call site,
47
+ # however deep inside the SDK the deprecated behaviour is detected
48
+ # (e.g. an unknown attribute found by Type#assign_attribute during
49
+ # HookMatcher.new). Best-effort like #warn_once.
50
+ #
51
+ # @param key [Object] once-guard key; any value usable in a Set
52
+ # @param message [String]
53
+ # @return [void]
54
+ def warn_once_at_caller(key, message)
55
+ first = @mutex.synchronize { @warned.add?(key) }
56
+ return unless first
57
+
58
+ begin
59
+ locations = caller_locations(1)
60
+ index = locations.index { |location| !sdk_frame?(location) }
61
+ index ? warn(message, uplevel: index + 1) : warn(message)
62
+ rescue StandardError
63
+ nil
64
+ end
65
+ end
66
+
45
67
  # Test hook: forget which deprecations were already reported.
46
68
  def reset!
47
69
  @mutex.synchronize { @warned.clear }
48
70
  end
71
+
72
+ private
73
+
74
+ # A frame inside the gem's lib/ (both the loaded and the real path, in
75
+ # case lib/ is reached through a symlink), or a Ruby-internal one
76
+ # (<internal:...>, e.g. Array#each on 3.4). A C frame such as Class#new
77
+ # reports its caller's path, so it counts as the caller's.
78
+ def sdk_frame?(location)
79
+ path = location.absolute_path || location.path
80
+ return true if path.nil? || path.start_with?('<internal:')
81
+
82
+ SDK_LIB_DIRS.any? { |dir| path.start_with?(dir) }
83
+ end
49
84
  end
85
+
86
+ SDK_LIB_DIRS = [File.expand_path('..', File.dirname(__FILE__)), File.expand_path('..', __dir__)]
87
+ .uniq.map { |dir| "#{dir}/" }.freeze
88
+ private_constant :SDK_LIB_DIRS
50
89
  end
51
90
  end
@@ -71,6 +71,8 @@ module ClaudeAgentSDK
71
71
  # scheduler-aware and park only the stream task; CPU-bound or
72
72
  # scheduler-opaque work must be moved by the user (a producer Thread
73
73
  # feeding a Thread::Queue, or FiberBoundary.invoke inside the enumerator).
74
+ #
75
+ # @api private
74
76
  module FiberBoundary
75
77
  # Raised by .invoke when a timeout-bounded call exceeds its allotted time.
76
78
  # The worker thread is abandoned (cancellation is best-effort; the
@@ -34,7 +34,7 @@ module ClaudeAgentSDK
34
34
  # observer = ClaudeAgentSDK::Instrumentation::OTelObserver.new
35
35
  # options = ClaudeAgentSDK::ClaudeAgentOptions.new(observers: [observer])
36
36
  # ClaudeAgentSDK.query(prompt: "Hello", options: options) { |msg| ... }
37
- class OTelObserver
37
+ class OTelObserver # rubocop:disable Metrics/ClassLength -- one observer mapping every message type to spans
38
38
  include ClaudeAgentSDK::Observer
39
39
 
40
40
  TRACER_NAME = 'claude_agent_sdk'
@@ -124,7 +124,7 @@ module ClaudeAgentSDK
124
124
 
125
125
  private
126
126
 
127
- def start_trace(message)
127
+ def start_trace(message) # rubocop:disable Metrics/AbcSize -- flat mapping of init-message fields to root span attributes
128
128
  # A new init without an intervening ResultMessage (e.g. /clear or an
129
129
  # interrupted turn) supersedes the current trace; finish it so it is
130
130
  # exported instead of leaking as a never-ended span, and reset the
@@ -157,15 +157,21 @@ module ClaudeAgentSDK
157
157
  'session.id' => message.session_id
158
158
  }.merge(@default_attributes)
159
159
 
160
- attrs['claude_code.version'] = message.claude_code_version if message.respond_to?(:claude_code_version) && message.claude_code_version
160
+ if message.respond_to?(:claude_code_version) && message.claude_code_version
161
+ attrs['claude_code.version'] = message.claude_code_version
162
+ end
161
163
  attrs['claude_code.cwd'] = message.cwd if message.respond_to?(:cwd) && message.cwd
162
- attrs['claude_code.permission_mode'] = message.permission_mode if message.respond_to?(:permission_mode) && message.permission_mode
164
+ if message.respond_to?(:permission_mode) && message.permission_mode
165
+ attrs['claude_code.permission_mode'] = message.permission_mode
166
+ end
163
167
 
164
168
  @root_span = @tracer.start_span('claude_agent.session', attributes: compact_attrs(attrs))
165
169
  @root_context = OpenTelemetry::Trace.context_with_span(@root_span)
166
170
 
167
171
  # Apply buffered prompt if on_user_prompt was called before InitMessage arrived
168
- @root_span.set_attribute('input.value', truncate(@first_user_input)) if @first_user_input && !@first_user_input.empty?
172
+ return unless @first_user_input && !@first_user_input.empty?
173
+
174
+ @root_span.set_attribute('input.value', truncate(@first_user_input))
169
175
  end
170
176
 
171
177
  def handle_assistant(message)
@@ -222,7 +228,7 @@ module ClaudeAgentSDK
222
228
  end
223
229
  end
224
230
 
225
- def end_trace(message)
231
+ def end_trace(message) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- flat mapping of optional usage/cost fields to span attributes
226
232
  return unless @root_span
227
233
 
228
234
  usage = message.usage || {}
@@ -235,7 +241,9 @@ module ClaudeAgentSDK
235
241
  # tokens (Anthropic's input_tokens excludes them; OpenInference's own
236
242
  # Anthropic instrumentation sums them in). gen_ai.usage.* keys keep
237
243
  # the raw exclusive values — Langfuse prices those additively.
238
- prompt_tokens = (input_tokens || 0) + (cache_creation_tokens || 0) + (cache_read_tokens || 0) if input_tokens || cache_creation_tokens || cache_read_tokens
244
+ if input_tokens || cache_creation_tokens || cache_read_tokens
245
+ prompt_tokens = (input_tokens || 0) + (cache_creation_tokens || 0) + (cache_read_tokens || 0)
246
+ end
239
247
  total_tokens = (prompt_tokens || 0) + (output_tokens || 0) if prompt_tokens || output_tokens
240
248
 
241
249
  # Set trace output (last assistant response — shown in Langfuse UI)