claude_agent 0.7.15 → 0.7.16

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.
data/docs/queries.md ADDED
@@ -0,0 +1,227 @@
1
+ # One-Shot Queries
2
+
3
+ One-shot queries send a single prompt to the Claude Code CLI and return the response. No persistent connection or multi-turn state is maintained. For multi-turn conversations, see `Conversation`.
4
+
5
+ The SDK provides three query methods at increasing levels of control:
6
+
7
+ | Method | Returns | Config integration | Streaming | Best for |
8
+ |--------------------------|-----------------------|----------------------------|----------------------|--------------------------------------|
9
+ | `ClaudeAgent.ask` | `TurnResult` | Yes (merges global config) | Block form | Most applications |
10
+ | `ClaudeAgent.query_turn` | `TurnResult` | No (explicit Options) | Block + EventHandler | Custom transports, event dispatch |
11
+ | `ClaudeAgent.query` | `Enumerator<Message>` | No (explicit Options) | Enumerator | Full control over message processing |
12
+
13
+ ## ClaudeAgent.ask
14
+
15
+ The primary entry point. Merges per-request keyword arguments with the global `Configuration`, builds an `Options` instance, and delegates to `query_turn`.
16
+
17
+ ```ruby
18
+ turn = ClaudeAgent.ask("What is 2+2?")
19
+ puts turn.text # => "4"
20
+ puts turn.cost # => 0.002
21
+ ```
22
+
23
+ ### With configuration overrides
24
+
25
+ Keyword arguments override global config for this request only:
26
+
27
+ ```ruby
28
+ turn = ClaudeAgent.ask("Fix the bug in auth.rb",
29
+ model: "opus",
30
+ max_turns: 5,
31
+ permission_mode: "acceptEdits"
32
+ )
33
+ ```
34
+
35
+ ### With callbacks
36
+
37
+ Pass `on_*` lambdas to receive events as they stream in. The method still returns a `TurnResult` after the turn completes.
38
+
39
+ ```ruby
40
+ turn = ClaudeAgent.ask("Explain Ruby GC",
41
+ on_text: ->(text) { print text },
42
+ on_tool_use: ->(tool) { puts "\nUsing: #{tool.name}" },
43
+ on_result: ->(result) { puts "\nCost: $#{result.total_cost_usd}" }
44
+ )
45
+ ```
46
+
47
+ Available callback keys correspond to `EventHandler` events: `on_text`, `on_thinking`, `on_tool_use`, `on_tool_result`, `on_result`, `on_assistant`, `on_stream_event`, `on_message` (catch-all), and others. See `EventHandler::EVENTS` for the full list.
48
+
49
+ ### With streaming block
50
+
51
+ The block receives each raw message as it arrives:
52
+
53
+ ```ruby
54
+ turn = ClaudeAgent.ask("Explain Ruby GC") do |msg|
55
+ case msg
56
+ when ClaudeAgent::AssistantMessage
57
+ print msg.text
58
+ when ClaudeAgent::ResultMessage
59
+ puts "\nDone in #{msg.duration_ms}ms"
60
+ end
61
+ end
62
+ ```
63
+
64
+ ### With explicit Options
65
+
66
+ Pass a pre-built `Options` to bypass the global `Configuration` entirely:
67
+
68
+ ```ruby
69
+ opts = ClaudeAgent::Options.new(
70
+ model: "claude-sonnet-4-5-20250514",
71
+ max_turns: 3,
72
+ permission_mode: "acceptEdits",
73
+ tools: ["Read", "Bash"]
74
+ )
75
+
76
+ turn = ClaudeAgent.ask("List files in /tmp", options: opts)
77
+ ```
78
+
79
+ ### With global configuration
80
+
81
+ Set defaults once, then call `ask` without repeating them:
82
+
83
+ ```ruby
84
+ ClaudeAgent.configure do |c|
85
+ c.model = "opus"
86
+ c.max_turns = 10
87
+ c.permission_mode = "acceptEdits"
88
+ end
89
+
90
+ # These calls inherit the global config
91
+ turn = ClaudeAgent.ask("Fix the failing test")
92
+ turn = ClaudeAgent.ask("Now update the docs", max_turns: 3) # override max_turns
93
+ ```
94
+
95
+ ## ClaudeAgent.query_turn
96
+
97
+ Wraps `query` and accumulates all messages into a `TurnResult`. Use this when you need to pass an explicit `Options` or `EventHandler` without going through `Configuration`.
98
+
99
+ ```ruby
100
+ def query_turn(prompt:, options: nil, transport: nil, events: nil, &block)
101
+ ```
102
+
103
+ ### Basic usage
104
+
105
+ ```ruby
106
+ turn = ClaudeAgent.query_turn(prompt: "What is 2+2?")
107
+ puts turn.text
108
+ puts turn.cost
109
+ ```
110
+
111
+ ### With EventHandler
112
+
113
+ Build an `EventHandler` for typed event dispatch:
114
+
115
+ ```ruby
116
+ events = ClaudeAgent::EventHandler.new
117
+ .on_text { |text| print text }
118
+ .on_tool_use { |tool| puts "Tool: #{tool.name}" }
119
+ .on_result { |r| puts "\nCost: $#{r.total_cost_usd}" }
120
+
121
+ turn = ClaudeAgent.query_turn(
122
+ prompt: "Refactor the parser",
123
+ options: ClaudeAgent::Options.new(model: "opus", max_turns: 5),
124
+ events: events
125
+ )
126
+ ```
127
+
128
+ ### With block
129
+
130
+ The block receives each message, just like the block form of `ask`:
131
+
132
+ ```ruby
133
+ turn = ClaudeAgent.query_turn(prompt: "Explain closures") do |msg|
134
+ print msg.text if msg.is_a?(ClaudeAgent::AssistantMessage)
135
+ end
136
+
137
+ puts turn.session_id
138
+ ```
139
+
140
+ ### With custom transport
141
+
142
+ Inject a transport for testing or custom subprocess management:
143
+
144
+ ```ruby
145
+ transport = ClaudeAgent::Transport::Subprocess.new(options: opts)
146
+ turn = ClaudeAgent.query_turn(prompt: "Hello", options: opts, transport: transport)
147
+ ```
148
+
149
+ ## ClaudeAgent.query
150
+
151
+ The lowest-level one-shot interface. Returns an `Enumerator` that yields each `Message` as it arrives from the CLI. You are responsible for iterating and interpreting message types.
152
+
153
+ ```ruby
154
+ def query(prompt:, options: nil, transport: nil)
155
+ ```
156
+
157
+ ### Basic usage with case statement
158
+
159
+ ```ruby
160
+ ClaudeAgent.query(prompt: "What is 2+2?").each do |message|
161
+ case message
162
+ when ClaudeAgent::SystemMessage
163
+ # Init message with session metadata
164
+ when ClaudeAgent::AssistantMessage
165
+ print message.text
166
+ when ClaudeAgent::UserMessage
167
+ # Tool results (system-generated)
168
+ when ClaudeAgent::StreamEvent
169
+ # Streaming deltas
170
+ when ClaudeAgent::ResultMessage
171
+ puts "\nCost: $#{message.total_cost_usd}"
172
+ puts "Duration: #{message.duration_ms}ms"
173
+ puts "Session: #{message.session_id}"
174
+ end
175
+ end
176
+ ```
177
+
178
+ ### Collecting all messages
179
+
180
+ ```ruby
181
+ messages = ClaudeAgent.query(prompt: "Hello").to_a
182
+ result = messages.find { |m| m.is_a?(ClaudeAgent::ResultMessage) }
183
+ puts result.total_cost_usd
184
+ ```
185
+
186
+ ### With custom options
187
+
188
+ ```ruby
189
+ options = ClaudeAgent::Options.new(
190
+ model: "claude-sonnet-4-5-20250514",
191
+ max_turns: 5,
192
+ permission_mode: "acceptEdits"
193
+ )
194
+
195
+ ClaudeAgent.query(prompt: "Fix the bug", options: options).each do |message|
196
+ # ...
197
+ end
198
+ ```
199
+
200
+ ## TurnResult
201
+
202
+ All three methods ultimately produce a `TurnResult` (for `query`, you build one yourself or use `query_turn`). Key accessors:
203
+
204
+ | Accessor | Type | Description |
205
+ |-------------------|-----------------|---------------------------------------------|
206
+ | `text` | `String` | All assistant text concatenated |
207
+ | `thinking` | `String` | All thinking content concatenated |
208
+ | `tool_uses` | `Array` | Tool use blocks from assistant messages |
209
+ | `tool_results` | `Array` | Tool result blocks from user messages |
210
+ | `tool_executions` | `Array<Hash>` | Matched `{ tool_use:, tool_result: }` pairs |
211
+ | `result` | `ResultMessage` | Final result message (nil if incomplete) |
212
+ | `cost` | `Float` | Total cost in USD |
213
+ | `usage` | `Hash` | Token usage breakdown |
214
+ | `duration_ms` | `Integer` | Wall-clock duration |
215
+ | `session_id` | `String` | Session ID for resumption |
216
+ | `model` | `String` | Model used |
217
+ | `success?` | `Boolean` | Whether the turn completed without error |
218
+ | `error?` | `Boolean` | Whether the turn ended with an error |
219
+ | `messages` | `Array` | All raw messages received |
220
+
221
+ ## Choosing the Right Method
222
+
223
+ **Use `ask`** when you want the simplest path with global configuration support. This is the right choice for most applications.
224
+
225
+ **Use `query_turn`** when you need explicit `Options` or `EventHandler` without going through `Configuration`, or when injecting a custom transport.
226
+
227
+ **Use `query`** when you need full control over message iteration -- for example, to build custom accumulators, forward messages to another system, or handle message types that `TurnResult` does not expose.
data/docs/sessions.md ADDED
@@ -0,0 +1,335 @@
1
+ # Sessions
2
+
3
+ Claude Code CLI persists every conversation as a session on disk. The SDK can find, inspect, mutate, fork, and resume these sessions without spawning a CLI subprocess -- all operations read and write the session JSONL files directly.
4
+
5
+ ## Session Resource
6
+
7
+ The `Session` class wraps `SessionInfo` with a rich, Rails-like API for discovering and working with past sessions.
8
+
9
+ ### Finding a Session
10
+
11
+ Use `Session.find` for a safe lookup that returns `nil` when the session does not exist, or `Session.retrieve` when you want an exception on missing sessions.
12
+
13
+ ```ruby
14
+ # Returns Session or nil (targeted lookup by ID, not a full scan)
15
+ session = ClaudeAgent::Session.find("abc-123-def-456")
16
+
17
+ # Returns Session or raises ClaudeAgent::NotFoundError
18
+ session = ClaudeAgent::Session.retrieve("abc-123-def-456")
19
+ ```
20
+
21
+ Both accept an optional `dir:` keyword to scope the search to a specific project directory:
22
+
23
+ ```ruby
24
+ session = ClaudeAgent::Session.find("abc-123-def-456", dir: "/path/to/project")
25
+ ```
26
+
27
+ ### Listing Sessions
28
+
29
+ ```ruby
30
+ # All sessions across all projects, sorted by last modified (most recent first)
31
+ sessions = ClaudeAgent::Session.all
32
+
33
+ # With optional filters
34
+ sessions = ClaudeAgent::Session.where(dir: "/path/to/project", limit: 10)
35
+ ```
36
+
37
+ ### Fields
38
+
39
+ Every `Session` exposes the following attributes:
40
+
41
+ | Field | Type | Description |
42
+ |-----------------|------------------|-----------------------------------------------------------------------------------|
43
+ | `session_id` | `String` | UUID of the session. |
44
+ | `summary` | `String` | Display summary: custom title, last auto-summary, first prompt, or `"(session)"`. |
45
+ | `last_modified` | `Integer` | Last modification time as epoch milliseconds. |
46
+ | `file_size` | `Integer` | Size of the session JSONL file in bytes. |
47
+ | `custom_title` | `String`, `nil` | User-assigned title, if any. |
48
+ | `first_prompt` | `String`, `nil` | First meaningful user prompt (truncated to 200 chars). |
49
+ | `git_branch` | `String`, `nil` | Git branch active during the session. |
50
+ | `cwd` | `String`, `nil` | Working directory the session was started in. |
51
+ | `tag` | `String`, `nil` | User-assigned tag, if any. |
52
+ | `created_at` | `Integer`, `nil` | Creation timestamp (epoch milliseconds), if available. |
53
+
54
+ ```ruby
55
+ session = ClaudeAgent::Session.retrieve("abc-123-def-456")
56
+
57
+ puts session.summary # => "Fix login bug"
58
+ puts session.git_branch # => "fix/login"
59
+ puts session.custom_title # => nil (no custom title set)
60
+ puts session.cwd # => "/Users/dev/myapp"
61
+ ```
62
+
63
+ ### Messages
64
+
65
+ `session.messages` returns a chainable, `Enumerable` `SessionMessageRelation`. Messages are loaded lazily on first access.
66
+
67
+ ```ruby
68
+ session = ClaudeAgent::Session.retrieve("abc-123-def-456")
69
+
70
+ # All messages
71
+ session.messages.each { |m| puts "#{m.type}: #{m.uuid}" }
72
+
73
+ # Pagination via .where
74
+ session.messages.where(limit: 10).to_a
75
+ session.messages.where(limit: 10, offset: 5).to_a
76
+
77
+ # Enumerable methods work directly
78
+ session.messages.first
79
+ session.messages.count
80
+ session.messages.select { |m| m.type == "assistant" }
81
+ session.messages.map(&:uuid)
82
+ ```
83
+
84
+ Each message in the relation is a `SessionMessage` with these fields:
85
+
86
+ | Field | Type | Description |
87
+ |----------------------|-----------------|---------------------------------------------------|
88
+ | `type` | `String` | `"user"` or `"assistant"`. |
89
+ | `uuid` | `String` | Message UUID. |
90
+ | `session_id` | `String` | Session UUID this message belongs to. |
91
+ | `message` | `Hash` | Raw message payload (role, content blocks, etc.). |
92
+ | `parent_tool_use_id` | `String`, `nil` | Parent tool use ID for tool result messages. |
93
+
94
+ ### Mutations
95
+
96
+ #### Renaming
97
+
98
+ ```ruby
99
+ session.rename("My descriptive title")
100
+ session.custom_title # => "My descriptive title"
101
+ ```
102
+
103
+ Appends a `custom-title` JSONL entry to the session file. The `custom_title` attribute is updated in place.
104
+
105
+ #### Tagging
106
+
107
+ ```ruby
108
+ session.tag_session("important")
109
+ session.tag # => "important"
110
+
111
+ # Clear the tag
112
+ session.tag_session(nil)
113
+ session.tag # => nil
114
+ ```
115
+
116
+ Appends a `tag` JSONL entry. Unicode zero-width and directional characters are automatically stripped from tag values.
117
+
118
+ Both mutations return `self` for chaining:
119
+
120
+ ```ruby
121
+ session.rename("Refactored auth module").tag_session("refactor")
122
+ ```
123
+
124
+ ### Forking
125
+
126
+ Create a new session by copying an existing one. All UUIDs in the forked session are remapped to fresh values.
127
+
128
+ ```ruby
129
+ # Fork the entire session
130
+ forked = session.fork
131
+ forked.session_id # => new UUID
132
+
133
+ # Fork up to a specific message (inclusive)
134
+ forked = session.fork(up_to: "message-uuid-here")
135
+
136
+ # Fork with a custom title
137
+ forked = session.fork(title: "Branch: try alternative approach")
138
+ ```
139
+
140
+ The returned value is a new `Session` instance pointing to the forked session file.
141
+
142
+ ### Reloading
143
+
144
+ Re-read session metadata from disk to pick up external changes:
145
+
146
+ ```ruby
147
+ session.reload
148
+ session.summary # reflects current file state
149
+ ```
150
+
151
+ Raises `ClaudeAgent::NotFoundError` if the session file no longer exists.
152
+
153
+ ### Resuming
154
+
155
+ Open a `Conversation` that continues from this session. The CLI restores full conversation context from the session transcript.
156
+
157
+ ```ruby
158
+ # Block form -- auto-closes when the block exits
159
+ session.resume(model: "opus") do |c|
160
+ turn = c.say("Continue where we left off")
161
+ puts turn.text
162
+ end
163
+
164
+ # Without a block -- caller is responsible for closing
165
+ conversation = session.resume(max_turns: 5)
166
+ conversation.say("What did we discuss last time?")
167
+ conversation.close
168
+ ```
169
+
170
+ Accepts the same keyword arguments as `Conversation.new`.
171
+
172
+ ## Functional API
173
+
174
+ The module-level methods provide direct access to session operations without wrapping results in `Session` objects. These return the underlying data types (`SessionInfo`, `SessionMessage`, `ForkSessionResult`) and are useful when you need lower-level control.
175
+
176
+ ### `ClaudeAgent.list_sessions`
177
+
178
+ ```ruby
179
+ # All sessions
180
+ sessions = ClaudeAgent.list_sessions
181
+ # => Array<SessionInfo>
182
+
183
+ # Scoped to a directory with pagination
184
+ sessions = ClaudeAgent.list_sessions(
185
+ dir: "/path/to/project",
186
+ limit: 20,
187
+ offset: 10,
188
+ include_worktrees: true # default: true
189
+ )
190
+ ```
191
+
192
+ When `dir` is inside a git repository and `include_worktrees` is `true`, sessions from all git worktree paths are included automatically.
193
+
194
+ ### `ClaudeAgent.get_session_info`
195
+
196
+ Targeted lookup of a single session by UUID. Returns `SessionInfo` or `nil`.
197
+
198
+ ```ruby
199
+ info = ClaudeAgent.get_session_info("abc-123-def-456")
200
+ info = ClaudeAgent.get_session_info("abc-123-def-456", dir: "/path/to/project")
201
+ ```
202
+
203
+ ### `ClaudeAgent.get_session_messages`
204
+
205
+ Read the conversation transcript for a session. Returns user and assistant messages in chronological order, reconstructing the main conversation thread from branches and forks.
206
+
207
+ ```ruby
208
+ messages = ClaudeAgent.get_session_messages("abc-123-def-456")
209
+ # => Array<SessionMessage>
210
+
211
+ messages = ClaudeAgent.get_session_messages("abc-123-def-456",
212
+ dir: "/path/to/project",
213
+ limit: 10,
214
+ offset: 5
215
+ )
216
+ ```
217
+
218
+ ### `ClaudeAgent.rename_session`
219
+
220
+ ```ruby
221
+ ClaudeAgent.rename_session("abc-123-def-456", "New title")
222
+ ClaudeAgent.rename_session("abc-123-def-456", "New title", dir: "/path/to/project")
223
+ ```
224
+
225
+ Raises `ArgumentError` if the title is empty, `ClaudeAgent::Error` if the session is not found.
226
+
227
+ ### `ClaudeAgent.tag_session`
228
+
229
+ ```ruby
230
+ ClaudeAgent.tag_session("abc-123-def-456", "important")
231
+ ClaudeAgent.tag_session("abc-123-def-456", nil) # clear tag
232
+ ClaudeAgent.tag_session("abc-123-def-456", "v2", dir: "/path/to/project")
233
+ ```
234
+
235
+ Raises `ClaudeAgent::Error` if the session is not found.
236
+
237
+ ### `ClaudeAgent.fork_session`
238
+
239
+ ```ruby
240
+ result = ClaudeAgent.fork_session("abc-123-def-456")
241
+ # => ForkSessionResult
242
+
243
+ result.session_id # => new UUID
244
+
245
+ result = ClaudeAgent.fork_session("abc-123-def-456",
246
+ up_to_message_id: "msg-uuid",
247
+ title: "Forked conversation",
248
+ dir: "/path/to/project"
249
+ )
250
+ ```
251
+
252
+ Raises `ArgumentError` if `up_to_message_id` is provided but not found in the session transcript.
253
+
254
+ ## V2 Session API (Unstable)
255
+
256
+ > **Warning:** The V2 Session API is unstable and may change without notice in any release. It is marked `@alpha` in the source and should not be used in production.
257
+
258
+ The V2 API provides a lower-level, multi-turn session interface that maps directly to the TypeScript SDK's `SDKSession` pattern. Unlike `Conversation`, it gives you explicit control over send/stream cycles.
259
+
260
+ ### Creating a Session
261
+
262
+ ```ruby
263
+ session = ClaudeAgent.unstable_v2_create_session(
264
+ model: "claude-sonnet-4-5-20250929",
265
+ permission_mode: "acceptEdits"
266
+ )
267
+ ```
268
+
269
+ ### Sending and Streaming
270
+
271
+ ```ruby
272
+ session.send("Hello, Claude!")
273
+
274
+ session.stream.each do |msg|
275
+ case msg
276
+ when ClaudeAgent::AssistantMessage
277
+ print msg.text
278
+ when ClaudeAgent::ResultMessage
279
+ puts "\nDone!"
280
+ end
281
+ end
282
+ ```
283
+
284
+ ### Resuming
285
+
286
+ ```ruby
287
+ session = ClaudeAgent.unstable_v2_resume_session(
288
+ "session-abc-123",
289
+ model: "claude-sonnet-4-5-20250929"
290
+ )
291
+ session.send("Continue our conversation")
292
+ session.stream.each { |msg| puts msg.inspect }
293
+ session.close
294
+ ```
295
+
296
+ ### One-Shot Prompt
297
+
298
+ ```ruby
299
+ result = ClaudeAgent.unstable_v2_prompt(
300
+ "What files are in this directory?",
301
+ model: "claude-sonnet-4-5-20250929"
302
+ )
303
+ puts result.text
304
+ ```
305
+
306
+ ### SessionOptions
307
+
308
+ `SessionOptions` is a `Data.define` type with the following fields:
309
+
310
+ | Field | Type | Description |
311
+ |----------------------------------|-----------------|----------------------------------------------------|
312
+ | `model` | `String` | Model identifier (required). |
313
+ | `path_to_claude_code_executable` | `String`, `nil` | Custom path to the Claude Code CLI binary. |
314
+ | `env` | `Hash`, `nil` | Environment variables to pass to the CLI process. |
315
+ | `allowed_tools` | `Array`, `nil` | Tools the agent is allowed to use. |
316
+ | `disallowed_tools` | `Array`, `nil` | Tools the agent is not allowed to use. |
317
+ | `can_use_tool` | `Proc`, `nil` | Callback for dynamic tool permission decisions. |
318
+ | `hooks` | `Hash`, `nil` | Hook configuration. |
319
+ | `permission_mode` | `String`, `nil` | Permission mode (e.g., `"acceptEdits"`, `"plan"`). |
320
+
321
+ ### Lifecycle
322
+
323
+ Always close V2 sessions when done to clean up the underlying CLI subprocess:
324
+
325
+ ```ruby
326
+ session = ClaudeAgent.unstable_v2_create_session(model: "claude-sonnet-4-5-20250929")
327
+ begin
328
+ session.send("Do something")
329
+ session.stream.each { |msg| process(msg) }
330
+ ensure
331
+ session.close
332
+ end
333
+
334
+ session.closed? # => true
335
+ ```
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Global configuration object (Stripe-style).
5
+ #
6
+ # Holds default values for all configurable Options fields. When a
7
+ # query, conversation, or ask/chat call is made without explicit
8
+ # Options, these defaults are merged with per-request overrides to
9
+ # produce the final Options instance.
10
+ #
11
+ # @example Module-level setters
12
+ # ClaudeAgent.model = "claude-sonnet-4-5-20250514"
13
+ # ClaudeAgent.permission_mode = "acceptEdits"
14
+ # ClaudeAgent.max_turns = 10
15
+ #
16
+ # @example Block-based bulk config
17
+ # ClaudeAgent.configure do |c|
18
+ # c.model = "claude-sonnet-4-5-20250514"
19
+ # c.permission_mode = "acceptEdits"
20
+ # c.max_turns = 10
21
+ # end
22
+ #
23
+ # @example Per-request overrides (via ask)
24
+ # ClaudeAgent.ask("Fix the bug", model: "opus", max_turns: 5)
25
+ #
26
+ class Configuration
27
+ # Tier 1: Module-level delegators (set once at boot)
28
+ TIER1_FIELDS = %i[
29
+ model permission_mode max_turns max_budget_usd
30
+ system_prompt append_system_prompt cli_path cwd
31
+ sandbox debug effort persist_session fallback_model
32
+ ].freeze
33
+
34
+ # Tier 2: Per-request overrides (common kwargs on ask/chat)
35
+ TIER2_FIELDS = %i[
36
+ tools allowed_tools disallowed_tools thinking output_format
37
+ ].freeze
38
+
39
+ # Tier 3: Advanced (accessible via config.xxx= or raw Options.new)
40
+ TIER3_FIELDS = %i[
41
+ mcp_servers hooks env extra_args agents setting_sources settings
42
+ plugins betas spawn_claude_code_process agent add_dirs
43
+ max_buffer_size stderr_callback include_partial_messages
44
+ enable_file_checkpointing prompt_suggestions strict_mcp_config
45
+ tool_config agent_progress_summaries max_thinking_tokens
46
+ debug_file
47
+ ].freeze
48
+
49
+ # All configurable fields
50
+ ALL_FIELDS = (TIER1_FIELDS + TIER2_FIELDS + TIER3_FIELDS).freeze
51
+
52
+ attr_accessor(*ALL_FIELDS)
53
+
54
+ # Global PermissionPolicy
55
+ attr_accessor :default_permissions
56
+
57
+ # Global HookRegistry
58
+ attr_accessor :default_hooks
59
+
60
+ # Global MCP server registrations
61
+ attr_accessor :default_mcp_servers
62
+
63
+ # Create a new Configuration with all fields at nil/default.
64
+ #
65
+ # @return [Configuration]
66
+ def self.setup
67
+ new
68
+ end
69
+
70
+ def initialize
71
+ @default_mcp_servers = {}
72
+ end
73
+
74
+ # Merge config defaults with per-request keyword overrides to produce an Options instance.
75
+ #
76
+ # nil overrides are ignored (config default wins). Explicit non-nil overrides win.
77
+ #
78
+ # @param overrides [Hash] Per-request keyword overrides
79
+ # @return [Options]
80
+ def to_options(**overrides)
81
+ merged = {}
82
+
83
+ ALL_FIELDS.each do |field|
84
+ config_val = public_send(field)
85
+ override_val = overrides.key?(field) ? overrides[field] : nil
86
+
87
+ # Per-request override wins if provided; config default otherwise
88
+ if overrides.key?(field)
89
+ merged[field] = override_val
90
+ elsif !config_val.nil?
91
+ merged[field] = config_val
92
+ end
93
+ end
94
+
95
+ # Forward any extra keys not in ALL_FIELDS (e.g., can_use_tool, permission_queue, etc.)
96
+ overrides.each do |key, value|
97
+ merged[key] = value unless ALL_FIELDS.include?(key)
98
+ end
99
+
100
+ # Wire in global permissions if no per-request can_use_tool provided
101
+ if default_permissions && !merged.key?(:can_use_tool) && !default_permissions.empty?
102
+ merged[:can_use_tool] = default_permissions.to_can_use_tool
103
+ end
104
+
105
+ # Wire in global hooks (additive merge with per-request hooks)
106
+ if default_hooks && !default_hooks.empty?
107
+ request_hooks = merged[:hooks]
108
+ global_hooks = default_hooks.to_hooks_hash
109
+ if request_hooks.is_a?(Hash)
110
+ # Merge: global + request (request hooks take precedence for same event)
111
+ combined = global_hooks.dup
112
+ request_hooks.each do |event, matchers|
113
+ combined[event] = (combined[event] || []) + Array(matchers)
114
+ end
115
+ merged[:hooks] = combined
116
+ else
117
+ merged[:hooks] = global_hooks
118
+ end
119
+ end
120
+
121
+ # Wire in global MCP servers
122
+ if !default_mcp_servers.empty? && !merged.key?(:mcp_servers)
123
+ merged[:mcp_servers] = default_mcp_servers.dup
124
+ end
125
+
126
+ Options.new(**merged)
127
+ end
128
+ end
129
+ end