claude-agent-sdk 0.35.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -0
  3. data/README.md +17 -8
  4. data/docs/cli-installer.md +16 -2
  5. data/docs/client.md +44 -4
  6. data/docs/errors.md +15 -1
  7. data/docs/hooks-and-permissions.md +27 -3
  8. data/docs/mcp-servers.md +36 -7
  9. data/docs/rails.md +3 -4
  10. data/docs/sessions.md +149 -34
  11. data/docs/types.md +106 -4
  12. data/lib/claude_agent_sdk/cancellation_signal.rb +2 -1
  13. data/lib/claude_agent_sdk/cli_installer.rb +68 -11
  14. data/lib/claude_agent_sdk/command_builder.rb +109 -98
  15. data/lib/claude_agent_sdk/deprecation.rb +90 -0
  16. data/lib/claude_agent_sdk/errors.rb +8 -0
  17. data/lib/claude_agent_sdk/fiber_boundary.rb +113 -2
  18. data/lib/claude_agent_sdk/instrumentation/otel.rb +15 -7
  19. data/lib/claude_agent_sdk/message_parser.rb +23 -9
  20. data/lib/claude_agent_sdk/observer.rb +2 -1
  21. data/lib/claude_agent_sdk/option_warnings.rb +2 -2
  22. data/lib/claude_agent_sdk/query.rb +99 -51
  23. data/lib/claude_agent_sdk/railtie.rb +14 -3
  24. data/lib/claude_agent_sdk/sdk_mcp_server.rb +58 -38
  25. data/lib/claude_agent_sdk/session_mutations.rb +28 -16
  26. data/lib/claude_agent_sdk/session_resume.rb +39 -35
  27. data/lib/claude_agent_sdk/session_store.rb +35 -21
  28. data/lib/claude_agent_sdk/session_summary.rb +12 -5
  29. data/lib/claude_agent_sdk/sessions.rb +112 -24
  30. data/lib/claude_agent_sdk/streaming.rb +1 -1
  31. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +75 -52
  32. data/lib/claude_agent_sdk/tasks/claude_agent_sdk.rake +12 -5
  33. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +15 -11
  34. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +19 -1
  35. data/lib/claude_agent_sdk/types/attributes.rb +271 -0
  36. data/lib/claude_agent_sdk/types/base.rb +320 -0
  37. data/lib/claude_agent_sdk/types/content_blocks.rb +57 -0
  38. data/lib/claude_agent_sdk/types/hooks.rb +640 -0
  39. data/lib/claude_agent_sdk/types/mcp.rb +232 -0
  40. data/lib/claude_agent_sdk/types/messages.rb +614 -0
  41. data/lib/claude_agent_sdk/types/option_values.rb +302 -0
  42. data/lib/claude_agent_sdk/types/options.rb +352 -0
  43. data/lib/claude_agent_sdk/types/permissions.rb +107 -0
  44. data/lib/claude_agent_sdk/types/sessions.rb +10 -0
  45. data/lib/claude_agent_sdk/types.rb +13 -2534
  46. data/lib/claude_agent_sdk/version.rb +1 -1
  47. data/lib/claude_agent_sdk.rb +308 -73
  48. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +3 -3
  49. metadata +12 -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}")
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgentSDK
4
+ # One-time deprecation warnings for public API slated for removal in the
5
+ # next major release (see the deprecation policy in issue #126).
6
+ #
7
+ # Emitted with plain Kernel#warn, deliberately NOT `category: :deprecated`:
8
+ # Ruby hides that category unless Warning[:deprecated] is enabled (off by
9
+ # default since 2.7.2, still off on 3.2-3.4), so a category-tagged warning
10
+ # would reach almost nobody before the removal. Plain warn is visible by
11
+ # default and still silenced by `-W0` / `$VERBOSE = nil`.
12
+ #
13
+ # @api private
14
+ module Deprecation
15
+ @warned = Set.new
16
+ @mutex = Mutex.new
17
+
18
+ class << self
19
+ # Warn once per process that ClaudeAgentSDK.+name+ is deprecated.
20
+ #
21
+ # Must be called directly from the deprecated method: `uplevel: 2`
22
+ # skips this frame and the deprecated method's, so the warning names
23
+ # the caller's file:line.
24
+ #
25
+ # Best-effort like OptionWarnings#emit: a closed or broken $stderr must
26
+ # not turn a still-supported call into an IOError. The name stays
27
+ # recorded either way (once per process means once).
28
+ #
29
+ # @param name [Symbol] the deprecated ClaudeAgentSDK module method
30
+ # @param replacement [String] the call to use instead, without the
31
+ # ClaudeAgentSDK. prefix
32
+ # @return [void]
33
+ def warn_once(name, replacement)
34
+ first = @mutex.synchronize { @warned.add?(name) }
35
+ return unless first
36
+
37
+ begin
38
+ warn("ClaudeAgentSDK.#{name} is deprecated and will be removed in 1.0; " \
39
+ "use ClaudeAgentSDK.#{replacement}", uplevel: 2)
40
+ rescue StandardError
41
+ nil
42
+ end
43
+ end
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
+
67
+ # Test hook: forget which deprecations were already reported.
68
+ def reset!
69
+ @mutex.synchronize { @warned.clear }
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
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
89
+ end
90
+ end
@@ -23,6 +23,14 @@ module ClaudeAgentSDK
23
23
  # missing manifest entry, checksum mismatch).
24
24
  class CLIInstallError < ClaudeSDKError; end
25
25
 
26
+ # Raised by the local-disk session APIs (list_sessions, get_session_*,
27
+ # rename/tag/delete/fork_session, import_session_to_store) when the Claude
28
+ # config directory cannot be located: CLAUDE_CONFIG_DIR is unset and there
29
+ # is no usable home directory for the default ~/.claude (HOME unset with no
30
+ # passwd entry, as under `docker --user` in a minimal image, or an empty or
31
+ # relative HOME). Set CLAUDE_CONFIG_DIR to fix it.
32
+ class ConfigDirError < ClaudeSDKError; end
33
+
26
34
  # Raised when the CLI process fails
27
35
  class ProcessError < ClaudeSDKError
28
36
  attr_reader :exit_code, :stderr