claude_agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ClaudeAgent
6
+ # Permission modes for tool execution (TypeScript SDK parity)
7
+ PERMISSION_MODES = %w[default acceptEdits plan bypassPermissions delegate dontAsk].freeze
8
+
9
+ # Configuration options for ClaudeAgent queries and clients
10
+ #
11
+ # @example Basic usage
12
+ # options = ClaudeAgent::Options.new(
13
+ # model: "claude-sonnet-4-5-20250514",
14
+ # max_turns: 10
15
+ # )
16
+ #
17
+ # @example With tools and permissions
18
+ # options = ClaudeAgent::Options.new(
19
+ # tools: ["Read", "Write", "Bash"],
20
+ # permission_mode: "acceptEdits",
21
+ # can_use_tool: ->(name, input, context) { { behavior: "allow" } }
22
+ # )
23
+ #
24
+ class Options
25
+ # Default values for options that have non-nil defaults
26
+ DEFAULTS = {
27
+ allowed_tools: [],
28
+ disallowed_tools: [],
29
+ allow_dangerously_skip_permissions: false,
30
+ continue_conversation: false,
31
+ fork_session: false,
32
+ strict_mcp_config: false,
33
+ mcp_servers: {},
34
+ add_dirs: [],
35
+ env: {},
36
+ extra_args: {},
37
+ plugins: [],
38
+ include_partial_messages: false,
39
+ enable_file_checkpointing: false,
40
+ persist_session: true,
41
+ betas: []
42
+ }.freeze
43
+
44
+ # All configurable attributes
45
+ ATTRIBUTES = %i[
46
+ tools allowed_tools disallowed_tools
47
+ system_prompt append_system_prompt
48
+ model fallback_model
49
+ permission_mode permission_prompt_tool_name can_use_tool allow_dangerously_skip_permissions
50
+ continue_conversation resume fork_session resume_session_at
51
+ max_turns max_budget_usd max_thinking_tokens
52
+ strict_mcp_config mcp_servers hooks
53
+ settings sandbox cwd add_dirs env user
54
+ cli_path extra_args agents setting_sources plugins
55
+ include_partial_messages output_format enable_file_checkpointing
56
+ persist_session betas max_buffer_size stderr_callback
57
+ abort_controller spawn_claude_code_process
58
+ ].freeze
59
+
60
+ attr_accessor(*ATTRIBUTES)
61
+
62
+ def initialize(**kwargs)
63
+ merged = DEFAULTS.merge(kwargs)
64
+ ATTRIBUTES.each do |attr|
65
+ instance_variable_set(:"@#{attr}", merged[attr])
66
+ end
67
+ validate!
68
+ end
69
+
70
+ # Build CLI arguments from options
71
+ # @return [Array<String>] CLI arguments
72
+ def to_cli_args
73
+ [].tap do |args|
74
+ args.concat(system_prompt_args)
75
+ args.concat(model_args)
76
+ args.concat(tools_args)
77
+ args.concat(permission_args)
78
+ args.concat(conversation_args)
79
+ args.concat(limits_args)
80
+ args.concat(mcp_args)
81
+ args.concat(settings_args)
82
+ args.concat(environment_args)
83
+ args.concat(output_args)
84
+ args.concat(extra_cli_args)
85
+ end
86
+ end
87
+
88
+ # Build environment variables for CLI process
89
+ # @return [Hash] Environment variables
90
+ def to_env
91
+ env.dup.tap do |process_env|
92
+ process_env["CLAUDE_CODE_ENTRYPOINT"] = "sdk-rb"
93
+ process_env["CLAUDE_AGENT_SDK_VERSION"] = ClaudeAgent::VERSION
94
+ process_env["CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING"] = "true" if enable_file_checkpointing
95
+ process_env["PWD"] = cwd.to_s if cwd
96
+ end
97
+ end
98
+
99
+ # Check if SDK MCP servers are configured
100
+ # @return [Boolean]
101
+ def has_sdk_mcp_servers?
102
+ return false unless mcp_servers.is_a?(Hash)
103
+
104
+ mcp_servers.any? { |_, v| v.is_a?(Hash) && v[:type] == "sdk" }
105
+ end
106
+
107
+ # Check if hooks are configured
108
+ # @return [Boolean]
109
+ def has_hooks?
110
+ hooks.is_a?(Hash) && hooks.any?
111
+ end
112
+
113
+ # Get the abort signal from the controller
114
+ # @return [AbortSignal, nil]
115
+ def abort_signal
116
+ abort_controller&.signal
117
+ end
118
+
119
+ private
120
+
121
+ # --- CLI Argument Builders ---
122
+
123
+ def system_prompt_args
124
+ [].tap do |args|
125
+ if system_prompt
126
+ case system_prompt
127
+ when String then args.push("--system-prompt", system_prompt)
128
+ when Hash then args.push("--system-prompt", JSON.generate(system_prompt))
129
+ end
130
+ end
131
+ args.push("--append-system-prompt", append_system_prompt) if append_system_prompt
132
+ end
133
+ end
134
+
135
+ def model_args
136
+ [].tap do |args|
137
+ args.push("--model", model) if model
138
+ args.push("--fallback-model", fallback_model) if fallback_model
139
+ end
140
+ end
141
+
142
+ def tools_args
143
+ [].tap do |args|
144
+ if tools
145
+ case tools
146
+ when Array then args.push("--tools", tools.join(","))
147
+ when ToolsPreset then args.push("--tools", JSON.generate(tools.to_h))
148
+ when Hash then args.push("--tools", JSON.generate(tools))
149
+ else args.push("--tools", tools.to_s)
150
+ end
151
+ end
152
+ args.push("--allowedTools", allowed_tools.join(",")) if allowed_tools.any?
153
+ args.push("--disallowedTools", disallowed_tools.join(",")) if disallowed_tools.any?
154
+ end
155
+ end
156
+
157
+ def permission_args
158
+ [].tap do |args|
159
+ args.push("--permission-mode", permission_mode) if permission_mode
160
+ args.push("--permission-prompt-tool", permission_prompt_tool_name) if permission_prompt_tool_name
161
+ args.push("--dangerously-skip-permissions") if allow_dangerously_skip_permissions
162
+ end
163
+ end
164
+
165
+ def conversation_args
166
+ [].tap do |args|
167
+ args.push("--continue") if continue_conversation
168
+ args.push("--resume", resume) if resume
169
+ args.push("--fork-session") if fork_session
170
+ args.push("--resume-session-at", resume_session_at) if resume_session_at
171
+ end
172
+ end
173
+
174
+ def limits_args
175
+ [].tap do |args|
176
+ args.push("--max-turns", max_turns.to_s) if max_turns
177
+ args.push("--max-budget-usd", max_budget_usd.to_s) if max_budget_usd
178
+ args.push("--max-thinking-tokens", max_thinking_tokens.to_s) if max_thinking_tokens
179
+ args.push("--strict-mcp-config") if strict_mcp_config
180
+ end
181
+ end
182
+
183
+ def mcp_args
184
+ [].tap do |args|
185
+ if mcp_servers.is_a?(Hash) && mcp_servers.any?
186
+ external_servers = mcp_servers.reject { |_, v| v.is_a?(Hash) && v[:type] == "sdk" }
187
+ args.push("--mcp-config", JSON.generate(external_servers)) if external_servers.any?
188
+ elsif mcp_servers.is_a?(String)
189
+ args.push("--mcp-config", mcp_servers)
190
+ end
191
+ end
192
+ end
193
+
194
+ def settings_args
195
+ [].tap do |args|
196
+ args.push("--settings", settings) if settings
197
+ if sandbox
198
+ sandbox_json = sandbox.respond_to?(:to_h) ? sandbox.to_h : sandbox
199
+ args.push("--sandbox", JSON.generate(sandbox_json))
200
+ end
201
+ end
202
+ end
203
+
204
+ def environment_args
205
+ [].tap do |args|
206
+ args.push("--user", user) if user
207
+ add_dirs.each { |dir| args.push("--add-dir", dir.to_s) }
208
+ args.push("--setting-sources", setting_sources.join(",")) if setting_sources&.any?
209
+ plugins.each do |plugin|
210
+ dir = plugin.is_a?(Hash) ? plugin[:dir] : plugin
211
+ args.push("--plugin-dir", dir.to_s)
212
+ end
213
+ args.push("--betas", betas.join(",")) if betas.any?
214
+ end
215
+ end
216
+
217
+ def output_args
218
+ [].tap do |args|
219
+ args.push("--enable-file-checkpointing") if enable_file_checkpointing
220
+ args.push("--no-persist-session") if persist_session == false
221
+ args.push("--json-schema", JSON.generate(output_format)) if output_format
222
+ args.push("--include-partial-messages") if include_partial_messages
223
+ if agents
224
+ agents_hash = agents.transform_values { |a| a.respond_to?(:to_h) ? a.to_h : a }
225
+ args.push("--agents", JSON.generate(agents_hash))
226
+ end
227
+ end
228
+ end
229
+
230
+ def extra_cli_args
231
+ [].tap do |args|
232
+ extra_args.each do |key, value|
233
+ flag = key.to_s.start_with?("--") ? key.to_s : "--#{key}"
234
+ value.nil? ? args.push(flag) : args.push(flag, value.to_s)
235
+ end
236
+ end
237
+ end
238
+
239
+ # --- Validation ---
240
+
241
+ def validate!
242
+ if permission_mode && !PERMISSION_MODES.include?(permission_mode)
243
+ raise ConfigurationError, "Invalid permission_mode: #{permission_mode}. Must be one of: #{PERMISSION_MODES.join(", ")}"
244
+ end
245
+
246
+ if permission_mode == "bypassPermissions" && !allow_dangerously_skip_permissions
247
+ raise ConfigurationError,
248
+ "Must set allow_dangerously_skip_permissions: true to use bypassPermissions mode"
249
+ end
250
+
251
+ if can_use_tool && !can_use_tool.respond_to?(:call)
252
+ raise ConfigurationError, "can_use_tool must be callable (Proc, Lambda, or object responding to #call)"
253
+ end
254
+
255
+ if max_turns && (!max_turns.is_a?(Integer) || max_turns < 1)
256
+ raise ConfigurationError, "max_turns must be a positive integer"
257
+ end
258
+
259
+ if max_budget_usd && (!max_budget_usd.is_a?(Numeric) || max_budget_usd <= 0)
260
+ raise ConfigurationError, "max_budget_usd must be a positive number"
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Result of a permission check (allow)
5
+ #
6
+ # @example Allow with modified input
7
+ # PermissionResultAllow.new(
8
+ # updated_input: input.merge("safe" => true)
9
+ # )
10
+ #
11
+ PermissionResultAllow = Data.define(:updated_input, :updated_permissions) do
12
+ def initialize(updated_input: nil, updated_permissions: nil)
13
+ super
14
+ end
15
+
16
+ def behavior
17
+ "allow"
18
+ end
19
+
20
+ def to_h
21
+ h = { behavior: "allow" }
22
+ h[:updatedInput] = updated_input if updated_input
23
+ h[:updatedPermissions] = updated_permissions&.map { |p| p.respond_to?(:to_h) ? p.to_h : p } if updated_permissions
24
+ h
25
+ end
26
+ end
27
+
28
+ # Result of a permission check (deny)
29
+ #
30
+ # @example Deny with message
31
+ # PermissionResultDeny.new(
32
+ # message: "Operation not allowed",
33
+ # interrupt: true
34
+ # )
35
+ #
36
+ PermissionResultDeny = Data.define(:message, :interrupt) do
37
+ def initialize(message: "", interrupt: false)
38
+ super
39
+ end
40
+
41
+ def behavior
42
+ "deny"
43
+ end
44
+
45
+ def to_h
46
+ { behavior: "deny", message: message, interrupt: interrupt }
47
+ end
48
+ end
49
+
50
+ # Valid permission update types
51
+ PERMISSION_UPDATE_TYPES = %w[
52
+ addRules
53
+ replaceRules
54
+ removeRules
55
+ setMode
56
+ addDirectories
57
+ removeDirectories
58
+ ].freeze
59
+
60
+ # Permission update request
61
+ #
62
+ # @example Add rules
63
+ # PermissionUpdate.new(
64
+ # type: "addRules",
65
+ # rules: [{tool_name: "Read", behavior: "allow"}]
66
+ # )
67
+ #
68
+ PermissionUpdate = Data.define(
69
+ :type,
70
+ :rules,
71
+ :behavior,
72
+ :mode,
73
+ :directories,
74
+ :destination
75
+ ) do
76
+ def initialize(
77
+ type:,
78
+ rules: nil,
79
+ behavior: nil,
80
+ mode: nil,
81
+ directories: nil,
82
+ destination: nil
83
+ )
84
+ super
85
+ end
86
+
87
+ def to_h
88
+ h = { type: type }
89
+ h[:rules] = rules.map { |r| normalize_rule(r) } if rules
90
+ h[:behavior] = behavior if behavior
91
+ h[:mode] = mode if mode
92
+ h[:directories] = directories if directories
93
+ h[:destination] = destination if destination
94
+ h
95
+ end
96
+
97
+ private
98
+
99
+ def normalize_rule(rule)
100
+ return rule unless rule.is_a?(Hash)
101
+
102
+ # Convert snake_case to camelCase
103
+ # Note: behavior is NOT part of PermissionRuleValue per TypeScript SDK
104
+ {
105
+ toolName: rule[:tool_name] || rule[:toolName],
106
+ ruleContent: rule[:rule_content] || rule[:ruleContent]
107
+ }.compact
108
+ end
109
+ end
110
+
111
+ # Permission rule value (TypeScript SDK parity)
112
+ # Note: behavior is on PermissionUpdate, not on individual rules
113
+ #
114
+ PermissionRuleValue = Data.define(:tool_name, :rule_content) do
115
+ def initialize(tool_name: nil, rule_content: nil)
116
+ super
117
+ end
118
+
119
+ def to_h
120
+ {
121
+ toolName: tool_name,
122
+ ruleContent: rule_content
123
+ }.compact
124
+ end
125
+ end
126
+
127
+ # Valid permission update destinations (TypeScript SDK parity)
128
+ PERMISSION_UPDATE_DESTINATIONS = %w[
129
+ userSettings
130
+ projectSettings
131
+ localSettings
132
+ session
133
+ cliArg
134
+ ].freeze
135
+
136
+ # Context provided to can_use_tool callbacks (TypeScript SDK parity)
137
+ #
138
+ # @example
139
+ # context = ToolPermissionContext.new(
140
+ # permission_suggestions: [update1, update2],
141
+ # blocked_path: "/etc/passwd",
142
+ # decision_reason: "Path outside allowed directories",
143
+ # tool_use_id: "tool_123",
144
+ # agent_id: "agent_456"
145
+ # )
146
+ #
147
+ ToolPermissionContext = Data.define(
148
+ :permission_suggestions,
149
+ :blocked_path,
150
+ :decision_reason,
151
+ :tool_use_id,
152
+ :agent_id
153
+ ) do
154
+ def initialize(
155
+ permission_suggestions: nil,
156
+ blocked_path: nil,
157
+ decision_reason: nil,
158
+ tool_use_id: nil,
159
+ agent_id: nil
160
+ )
161
+ super
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ class << self
5
+ # One-shot query to Claude Code CLI
6
+ #
7
+ # This is a simple, stateless interface for sending a single prompt
8
+ # and receiving all responses. For interactive conversations, use
9
+ # {ClaudeAgent::Client} instead.
10
+ #
11
+ # @param prompt [String] The prompt to send to Claude
12
+ # @param options [Options, nil] Configuration options
13
+ # @param transport [Transport::Base, nil] Custom transport (default: Subprocess)
14
+ # @return [Enumerator<Message>] Enumerator yielding Message objects
15
+ #
16
+ # @example Basic usage
17
+ # ClaudeAgent.query(prompt: "What is 2+2?").each do |message|
18
+ # case message
19
+ # when ClaudeAgent::AssistantMessage
20
+ # puts message.text
21
+ # when ClaudeAgent::ResultMessage
22
+ # puts "Cost: $#{message.total_cost_usd}"
23
+ # end
24
+ # end
25
+ #
26
+ # @example Collect all messages
27
+ # messages = ClaudeAgent.query(prompt: "Hello").to_a
28
+ # result = messages.last
29
+ # puts "Completed in #{result.duration_ms}ms"
30
+ #
31
+ # @example With custom options
32
+ # options = ClaudeAgent::Options.new(
33
+ # model: "claude-sonnet-4-5-20250514",
34
+ # max_turns: 5,
35
+ # permission_mode: "acceptEdits"
36
+ # )
37
+ # ClaudeAgent.query(prompt: "Fix the bug", options: options).each { |m| puts m }
38
+ #
39
+ def query(prompt:, options: nil, transport: nil)
40
+ options ||= Options.new
41
+ transport ||= Transport::Subprocess.new(options: options)
42
+
43
+ Enumerator.new do |yielder|
44
+ # Set entrypoint environment variable
45
+ ENV["CLAUDE_CODE_ENTRYPOINT"] = "sdk-rb"
46
+
47
+ # Determine mode based on hooks/MCP servers
48
+ streaming = options.has_hooks? || options.has_sdk_mcp_servers?
49
+
50
+ if streaming
51
+ # Use streaming mode with control protocol
52
+ protocol = ControlProtocol.new(transport: transport, options: options)
53
+ begin
54
+ # Register abort handler if abort controller is provided
55
+ if options.abort_signal
56
+ options.abort_signal.on_abort do
57
+ protocol.abort! rescue nil
58
+ end
59
+ end
60
+
61
+ protocol.start(streaming: true)
62
+ protocol.send_user_message(prompt)
63
+ transport.end_input unless options.has_hooks? || options.has_sdk_mcp_servers?
64
+
65
+ protocol.each_message do |message|
66
+ yielder << message
67
+ break if message.is_a?(ResultMessage)
68
+ end
69
+ ensure
70
+ protocol.stop
71
+ end
72
+ else
73
+ # Simple mode - just send prompt and read responses
74
+ parser = MessageParser.new
75
+ begin
76
+ transport.connect(streaming: false, prompt: prompt)
77
+
78
+ transport.read_messages do |raw|
79
+ message = parser.parse(raw)
80
+ yielder << message
81
+ break if message.is_a?(ResultMessage)
82
+ end
83
+ ensure
84
+ transport.close
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Network-specific configuration for sandbox mode (TypeScript SDK parity)
5
+ #
6
+ # @example
7
+ # network = SandboxNetworkConfig.new(
8
+ # allow_local_binding: true,
9
+ # allow_unix_sockets: ["/var/run/docker.sock"],
10
+ # allowed_domains: ["api.example.com"]
11
+ # )
12
+ #
13
+ SandboxNetworkConfig = Data.define(
14
+ :allowed_domains,
15
+ :allow_local_binding,
16
+ :allow_unix_sockets,
17
+ :allow_all_unix_sockets,
18
+ :http_proxy_port,
19
+ :socks_proxy_port
20
+ ) do
21
+ def initialize(
22
+ allowed_domains: [],
23
+ allow_local_binding: false,
24
+ allow_unix_sockets: [],
25
+ allow_all_unix_sockets: false,
26
+ http_proxy_port: nil,
27
+ socks_proxy_port: nil
28
+ )
29
+ super
30
+ end
31
+
32
+ def to_h
33
+ result = {}
34
+ result[:allowedDomains] = allowed_domains unless allowed_domains.empty?
35
+ result[:allowLocalBinding] = allow_local_binding if allow_local_binding
36
+ result[:allowUnixSockets] = allow_unix_sockets unless allow_unix_sockets.empty?
37
+ result[:allowAllUnixSockets] = allow_all_unix_sockets if allow_all_unix_sockets
38
+ result[:httpProxyPort] = http_proxy_port if http_proxy_port
39
+ result[:socksProxyPort] = socks_proxy_port if socks_proxy_port
40
+ result
41
+ end
42
+ end
43
+
44
+ # Configuration for ignoring specific sandbox violations (TypeScript SDK parity)
45
+ #
46
+ # @example
47
+ # ignore = SandboxIgnoreViolations.new(
48
+ # file: ["/tmp/*"],
49
+ # network: ["localhost:*"]
50
+ # )
51
+ #
52
+ SandboxIgnoreViolations = Data.define(:file, :network) do
53
+ def initialize(file: [], network: [])
54
+ super
55
+ end
56
+
57
+ def to_h
58
+ result = {}
59
+ result[:file] = file unless file.empty?
60
+ result[:network] = network unless network.empty?
61
+ result
62
+ end
63
+ end
64
+
65
+ # Custom ripgrep configuration for sandbox mode (TypeScript SDK parity)
66
+ #
67
+ # @example
68
+ # ripgrep = SandboxRipgrepConfig.new(
69
+ # command: "/usr/local/bin/rg",
70
+ # args: ["--hidden"]
71
+ # )
72
+ #
73
+ SandboxRipgrepConfig = Data.define(:command, :args) do
74
+ def initialize(command:, args: nil)
75
+ super
76
+ end
77
+
78
+ def to_h
79
+ result = { command: command }
80
+ result[:args] = args if args
81
+ result
82
+ end
83
+ end
84
+
85
+ # Sandbox configuration for command execution (TypeScript SDK parity)
86
+ #
87
+ # @example Basic sandbox
88
+ # sandbox = SandboxSettings.new(enabled: true)
89
+ #
90
+ # @example With network config
91
+ # sandbox = SandboxSettings.new(
92
+ # enabled: true,
93
+ # auto_allow_bash_if_sandboxed: true,
94
+ # excluded_commands: ["docker"],
95
+ # network: SandboxNetworkConfig.new(allow_local_binding: true)
96
+ # )
97
+ #
98
+ # @example With custom ripgrep
99
+ # sandbox = SandboxSettings.new(
100
+ # enabled: true,
101
+ # ripgrep: SandboxRipgrepConfig.new(command: "/usr/local/bin/rg")
102
+ # )
103
+ #
104
+ SandboxSettings = Data.define(
105
+ :enabled,
106
+ :auto_allow_bash_if_sandboxed,
107
+ :excluded_commands,
108
+ :allow_unsandboxed_commands,
109
+ :network,
110
+ :ignore_violations,
111
+ :enable_weaker_nested_sandbox,
112
+ :ripgrep
113
+ ) do
114
+ def initialize(
115
+ enabled: false,
116
+ auto_allow_bash_if_sandboxed: false,
117
+ excluded_commands: [],
118
+ allow_unsandboxed_commands: false,
119
+ network: nil,
120
+ ignore_violations: nil,
121
+ enable_weaker_nested_sandbox: false,
122
+ ripgrep: nil
123
+ )
124
+ super
125
+ end
126
+
127
+ def to_h
128
+ result = { enabled: enabled }
129
+ result[:autoAllowBashIfSandboxed] = auto_allow_bash_if_sandboxed if auto_allow_bash_if_sandboxed
130
+ result[:excludedCommands] = excluded_commands unless excluded_commands.empty?
131
+ result[:allowUnsandboxedCommands] = allow_unsandboxed_commands if allow_unsandboxed_commands
132
+ result[:network] = network.to_h if network && !network.to_h.empty?
133
+ result[:ignoreViolations] = ignore_violations.to_h if ignore_violations && !ignore_violations.to_h.empty?
134
+ result[:enableWeakerNestedSandbox] = enable_weaker_nested_sandbox if enable_weaker_nested_sandbox
135
+ result[:ripgrep] = ripgrep.to_h if ripgrep
136
+ result
137
+ end
138
+ end
139
+ end