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.
@@ -226,13 +226,38 @@ module ClaudeAgent
226
226
  [ callbacks, conversation_kwargs, options_kwargs ]
227
227
  end
228
228
 
229
+ # Symbol → CLI permission mode mapping
230
+ SYMBOL_PERMISSION_MODES = {
231
+ default: "default",
232
+ accept_edits: "acceptEdits",
233
+ plan: "plan",
234
+ bypass_permissions: "bypassPermissions",
235
+ dont_ask: "dontAsk"
236
+ }.freeze
237
+
229
238
  def build_options(options_kwargs, conversation_kwargs)
230
239
  permission = conversation_kwargs[:on_permission]
231
240
 
232
- if permission.respond_to?(:call)
233
- options_kwargs[:can_use_tool] = permission
234
- elsif permission == :queue || permission.nil?
241
+ case permission
242
+ when PermissionPolicy
243
+ options_kwargs[:can_use_tool] = permission.to_can_use_tool
244
+ when Symbol
245
+ if (mode = SYMBOL_PERMISSION_MODES[permission])
246
+ options_kwargs[:permission_mode] = mode
247
+ elsif permission == :queue
248
+ options_kwargs[:permission_queue] = true unless options_kwargs.key?(:can_use_tool)
249
+ end
250
+ when nil
235
251
  options_kwargs[:permission_queue] = true unless options_kwargs.key?(:can_use_tool)
252
+ else
253
+ if permission.respond_to?(:call)
254
+ options_kwargs[:can_use_tool] = permission
255
+ end
256
+ end
257
+
258
+ # Handle HookRegistry in hooks option
259
+ if options_kwargs[:hooks].is_a?(HookRegistry)
260
+ options_kwargs[:hooks] = options_kwargs[:hooks].to_hooks_hash
236
261
  end
237
262
 
238
263
  Options.new(**options_kwargs)
@@ -87,6 +87,9 @@ module ClaudeAgent
87
87
  # Raised when an invalid option is provided
88
88
  class ConfigurationError < Error; end
89
89
 
90
+ # Raised when a resource is not found (Stripe convention)
91
+ class NotFoundError < Error; end
92
+
90
93
  # Raised when an operation is aborted/cancelled (TypeScript SDK parity)
91
94
  #
92
95
  # This error is raised when an operation is explicitly cancelled,
@@ -54,6 +54,20 @@ module ClaudeAgent
54
54
  # All known events
55
55
  EVENTS = (META_EVENTS + TYPE_EVENTS + DECOMPOSED_EVENTS).freeze
56
56
 
57
+ # Create an EventHandler with block-based DSL.
58
+ #
59
+ # @example
60
+ # handler = ClaudeAgent::EventHandler.define do
61
+ # on_text { |t| print t }
62
+ # on_result { |r| puts "\nCost: $#{r.total_cost_usd}" }
63
+ # end
64
+ #
65
+ # @yield [self] Block evaluated in the context of the new handler
66
+ # @return [EventHandler]
67
+ def self.define(&block)
68
+ new.tap { |h| h.instance_eval(&block) }
69
+ end
70
+
57
71
  def initialize
58
72
  @handlers = Hash.new { |h, k| h[k] = [] }
59
73
  @pending_tool_uses = {}
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Declarative hook registry with Ruby-friendly method names.
5
+ #
6
+ # Maps idiomatic Ruby method names to CLI hook event names and
7
+ # compiles to the Hash format consumed by Options#hooks.
8
+ #
9
+ # @example Basic usage
10
+ # hooks = ClaudeAgent::HookRegistry.new do |h|
11
+ # h.before_tool_use(/Bash/) { |input, ctx| { continue_: true } }
12
+ # h.after_tool_use("Read") { |input, ctx| { continue_: true } }
13
+ # end
14
+ #
15
+ # @example Module-level convenience
16
+ # ClaudeAgent.hooks do |h|
17
+ # h.on_session_start { |input, ctx| { continue_: true } }
18
+ # end
19
+ #
20
+ class HookRegistry
21
+ # Ruby method name → CLI event name mapping
22
+ EVENT_MAP = {
23
+ before_tool_use: "PreToolUse",
24
+ after_tool_use: "PostToolUse",
25
+ after_tool_use_failure: "PostToolUseFailure",
26
+ on_notification: "Notification",
27
+ on_user_prompt_submit: "UserPromptSubmit",
28
+ on_session_start: "SessionStart",
29
+ on_session_end: "SessionEnd",
30
+ on_stop: "Stop",
31
+ on_subagent_start: "SubagentStart",
32
+ on_subagent_stop: "SubagentStop",
33
+ before_compact: "PreCompact",
34
+ after_compact: "PostCompact",
35
+ on_permission_request: "PermissionRequest",
36
+ on_setup: "Setup",
37
+ on_teammate_idle: "TeammateIdle",
38
+ on_task_completed: "TaskCompleted",
39
+ on_elicitation: "Elicitation",
40
+ on_elicitation_result: "ElicitationResult",
41
+ on_config_change: "ConfigChange",
42
+ on_worktree_create: "WorktreeCreate",
43
+ on_worktree_remove: "WorktreeRemove",
44
+ on_instructions_loaded: "InstructionsLoaded"
45
+ }.freeze
46
+
47
+ # @param block [Proc] DSL block yielding self
48
+ def initialize(&block)
49
+ @matchers = Hash.new { |h, k| h[k] = [] }
50
+ yield self if block_given?
51
+ end
52
+
53
+ # Define DSL methods for each event
54
+ EVENT_MAP.each do |method_name, cli_event|
55
+ define_method(method_name) do |matcher = nil, timeout: nil, &callback|
56
+ @matchers[cli_event] << HookMatcher.new(
57
+ matcher: normalize_matcher(matcher),
58
+ callbacks: [ callback ],
59
+ timeout: timeout
60
+ )
61
+ self
62
+ end
63
+ end
64
+
65
+ # Compile to the hooks Hash format consumed by Options.
66
+ #
67
+ # @return [Hash{String => Array<HookMatcher>}]
68
+ def to_hooks_hash
69
+ @matchers.transform_values(&:dup)
70
+ end
71
+
72
+ # Merge another HookRegistry additively (concatenates matchers per event).
73
+ #
74
+ # @param other [HookRegistry] Registry to merge
75
+ # @return [HookRegistry] New registry with combined matchers
76
+ def merge(other)
77
+ merged = self.class.new
78
+ @matchers.each { |event, matchers| matchers.each { |m| merged.instance_variable_get(:@matchers)[event] << m } }
79
+ other.instance_variable_get(:@matchers).each { |event, matchers| matchers.each { |m| merged.instance_variable_get(:@matchers)[event] << m } }
80
+ merged
81
+ end
82
+
83
+ # Whether any hooks have been registered.
84
+ # @return [Boolean]
85
+ def empty?
86
+ @matchers.empty?
87
+ end
88
+
89
+ # Number of total matchers across all events.
90
+ # @return [Integer]
91
+ def size
92
+ @matchers.values.sum(&:size)
93
+ end
94
+
95
+ private
96
+
97
+ def normalize_matcher(matcher)
98
+ case matcher
99
+ when Regexp
100
+ matcher.source
101
+ when String
102
+ matcher
103
+ when nil
104
+ nil
105
+ else
106
+ matcher.to_s
107
+ end
108
+ end
109
+ end
110
+ end
@@ -35,11 +35,33 @@ module ClaudeAgent
35
35
  # @param name [String] Server name
36
36
  # @param tools [Array<Tool>] Tools to expose
37
37
  # @param logger [Logger, nil] Optional logger instance
38
+ # @yield [self] Optional block for DSL-style tool definition
39
+ #
40
+ # @example Block DSL
41
+ # server = ClaudeAgent::MCP::Server.new(name: "calc") do |s|
42
+ # s.tool("add", "Add numbers", { a: :number, b: :number }) { |args| args[:a] + args[:b] }
43
+ # end
44
+ #
38
45
  def initialize(name:, tools: [], logger: nil)
39
46
  @name = name.to_s
40
47
  @tools = {}
41
48
  @logger = logger
42
49
  tools.each { |tool| add_tool(tool) }
50
+ yield self if block_given?
51
+ end
52
+
53
+ # Define and add a tool in one step (DSL convenience)
54
+ #
55
+ # @param name [String] Tool name
56
+ # @param description [String] Tool description
57
+ # @param schema [Hash] Input schema
58
+ # @param annotations [Hash, nil] MCP tool annotations
59
+ # @yield [Hash] Tool arguments
60
+ # @return [Tool] The created tool
61
+ def tool(name, description, schema = {}, annotations: nil, &handler)
62
+ new_tool = Tool.new(name: name, description: description, schema: schema, annotations: annotations, &handler)
63
+ add_tool(new_tool)
64
+ new_tool
43
65
  end
44
66
 
45
67
  # Add a tool to the server
@@ -73,12 +73,12 @@ module ClaudeAgent
73
73
 
74
74
  private
75
75
 
76
- # Normalize schema from simple Ruby types to JSON Schema
76
+ # Normalize schema from simple Ruby types or symbol shortcuts to JSON Schema
77
77
  def normalize_schema(schema)
78
78
  return schema if json_schema?(schema)
79
79
 
80
- # Convert simple {name: Type} format to JSON Schema
81
- if schema.is_a?(Hash) && schema.values.all? { |v| v.is_a?(Class) || v.is_a?(Module) }
80
+ # Convert simple {name: Type} or {name: :type_symbol} format to JSON Schema
81
+ if schema.is_a?(Hash) && schema.values.all? { |v| v.is_a?(Class) || v.is_a?(Module) || v.is_a?(Symbol) }
82
82
  {
83
83
  type: "object",
84
84
  properties: schema.transform_values { |type| type_to_schema(type) },
@@ -96,7 +96,28 @@ module ClaudeAgent
96
96
  schema.key?(:properties) || schema.key?("properties")
97
97
  end
98
98
 
99
+ # Symbol type shortcuts for schema definitions
100
+ SYMBOL_TYPE_MAP = {
101
+ string: "string",
102
+ str: "string",
103
+ integer: "integer",
104
+ int: "integer",
105
+ number: "number",
106
+ float: "number",
107
+ numeric: "number",
108
+ boolean: "boolean",
109
+ bool: "boolean",
110
+ array: "array",
111
+ object: "object",
112
+ hash: "object"
113
+ }.freeze
114
+
99
115
  def type_to_schema(type)
116
+ if type.is_a?(Symbol)
117
+ json_type = SYMBOL_TYPE_MAP[type] || "string"
118
+ return { type: json_type }
119
+ end
120
+
100
121
  case type.to_s
101
122
  when "String"
102
123
  { type: "string" }
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Shared interface for all message and content block types.
5
+ #
6
+ # Provides a consistent API across the 22+ message types and 8 content
7
+ # block types without interfering with `Data.define` inheritance.
8
+ #
9
+ # @example Universal text extraction
10
+ # message.text_content # works on AssistantMessage, UserMessage, TextBlock, etc.
11
+ #
12
+ # @example Duck-type checks
13
+ # message.session_message? # has uuid + session_id?
14
+ # message.identifiable? # has uuid?
15
+ #
16
+ # @example Pattern matching
17
+ # case message
18
+ # in { type: :assistant }
19
+ # puts message.text_content
20
+ # in { type: :result }
21
+ # puts message.cost
22
+ # end
23
+ #
24
+ module Message
25
+ # Universal text extraction across all message and content block types.
26
+ #
27
+ # @return [String] Extracted text (empty string if no text available)
28
+ def text_content
29
+ case self
30
+ when AssistantMessage
31
+ text
32
+ when UserMessage, UserMessageReplay
33
+ content.is_a?(String) ? content : ""
34
+ when TextBlock
35
+ text
36
+ when ThinkingBlock
37
+ thinking
38
+ when StreamEvent
39
+ delta_text || ""
40
+ when ToolProgressMessage
41
+ ""
42
+ when ResultMessage
43
+ ""
44
+ when GenericMessage
45
+ raw.is_a?(Hash) ? (raw[:text] || raw["text"] || "").to_s : ""
46
+ else
47
+ respond_to?(:text) ? (text || "").to_s : ""
48
+ end
49
+ end
50
+
51
+ # Whether this message carries session identity (uuid + session_id).
52
+ #
53
+ # @return [Boolean]
54
+ def session_message?
55
+ respond_to?(:uuid) && respond_to?(:session_id) &&
56
+ !uuid.nil? && !session_id.nil?
57
+ end
58
+
59
+ # Whether this message has a UUID.
60
+ #
61
+ # @return [Boolean]
62
+ def identifiable?
63
+ respond_to?(:uuid) && !uuid.nil?
64
+ end
65
+
66
+ # Override deconstruct_keys to include :type for pattern matching.
67
+ #
68
+ # Allows `case msg; in { type: :assistant }` to work naturally.
69
+ #
70
+ # Data#deconstruct_keys stops early if it encounters a non-member key,
71
+ # so we filter out :type (a virtual key) before delegating to super.
72
+ #
73
+ # @param keys [Array<Symbol>, nil]
74
+ # @return [Hash]
75
+ def deconstruct_keys(keys)
76
+ if keys.nil?
77
+ { type: type }.merge(super)
78
+ elsif keys.include?(:type)
79
+ member_keys = keys - [ :type ]
80
+ base = member_keys.empty? ? {} : super(member_keys)
81
+ { type: type }.merge(base)
82
+ else
83
+ super
84
+ end
85
+ end
86
+ end
87
+
88
+ # Prepend Message in all message types (prepend needed to override Data#deconstruct_keys)
89
+ MESSAGE_TYPES.each { |klass| klass.prepend(Message) }
90
+
91
+ # Prepend Message in all content block types
92
+ CONTENT_BLOCK_TYPES.each { |klass| klass.prepend(Message) }
93
+ end
@@ -122,10 +122,20 @@ module ClaudeAgent
122
122
  "Must set allow_dangerously_skip_permissions: true to use bypassPermissions mode"
123
123
  end
124
124
 
125
+ # Auto-compile PermissionPolicy to can_use_tool lambda
126
+ if can_use_tool.is_a?(PermissionPolicy)
127
+ @can_use_tool = can_use_tool.to_can_use_tool
128
+ end
129
+
125
130
  if can_use_tool && !can_use_tool.respond_to?(:call)
126
131
  raise ConfigurationError, "can_use_tool must be callable (Proc, Lambda, or object responding to #call)"
127
132
  end
128
133
 
134
+ # Auto-compile HookRegistry to hooks hash
135
+ if hooks.is_a?(HookRegistry)
136
+ @hooks = hooks.to_hooks_hash
137
+ end
138
+
129
139
  if on_elicitation && !on_elicitation.respond_to?(:call)
130
140
  raise ConfigurationError, "on_elicitation must be callable (Proc, Lambda, or object responding to #call)"
131
141
  end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Declarative permission policy builder.
5
+ #
6
+ # Compiles allow/deny rules into a `can_use_tool` lambda for use
7
+ # with Options and Conversation. Rules are evaluated in order;
8
+ # first match wins.
9
+ #
10
+ # @example Basic usage
11
+ # policy = ClaudeAgent::PermissionPolicy.new do |p|
12
+ # p.allow "Read", "Grep", "Glob"
13
+ # p.deny "Bash", message: "Bash not allowed"
14
+ # p.deny_all
15
+ # end
16
+ #
17
+ # @example With regex matching
18
+ # policy = ClaudeAgent::PermissionPolicy.new do |p|
19
+ # p.allow_matching(/^mcp__/)
20
+ # p.deny_all
21
+ # end
22
+ #
23
+ # @example With custom handler for unmatched tools
24
+ # policy = ClaudeAgent::PermissionPolicy.new do |p|
25
+ # p.allow "Read"
26
+ # p.ask { |name, input, ctx| PermissionResultAllow.new }
27
+ # end
28
+ #
29
+ # @example Module-level convenience
30
+ # ClaudeAgent.permissions do |p|
31
+ # p.allow "Read", "Grep"
32
+ # p.deny "Bash"
33
+ # end
34
+ #
35
+ class PermissionPolicy
36
+ # @param block [Proc] DSL block yielding self
37
+ def initialize(&block)
38
+ @rules = []
39
+ @fallback = nil
40
+ yield self if block_given?
41
+ end
42
+
43
+ # Allow specific tools by exact name.
44
+ #
45
+ # @param tool_names [Array<String>] Tool names to allow
46
+ # @return [self]
47
+ def allow(*tool_names)
48
+ tool_names.flatten.each do |name|
49
+ @rules << { type: :exact, name: name.to_s, action: :allow }
50
+ end
51
+ self
52
+ end
53
+
54
+ # Deny specific tools by exact name.
55
+ #
56
+ # @param tool_names [Array<String>] Tool names to deny
57
+ # @param message [String] Denial message
58
+ # @param interrupt [Boolean] Whether to interrupt the conversation
59
+ # @return [self]
60
+ def deny(*tool_names, message: "", interrupt: false)
61
+ tool_names.flatten.each do |name|
62
+ @rules << { type: :exact, name: name.to_s, action: :deny, message: message, interrupt: interrupt }
63
+ end
64
+ self
65
+ end
66
+
67
+ # Allow tools matching a pattern.
68
+ #
69
+ # @param pattern [Regexp, String] Pattern to match against tool names
70
+ # @return [self]
71
+ def allow_matching(pattern)
72
+ @rules << { type: :pattern, pattern: to_regexp(pattern), action: :allow }
73
+ self
74
+ end
75
+
76
+ # Deny tools matching a pattern.
77
+ #
78
+ # @param pattern [Regexp, String] Pattern to match against tool names
79
+ # @param message [String] Denial message
80
+ # @param interrupt [Boolean] Whether to interrupt the conversation
81
+ # @return [self]
82
+ def deny_matching(pattern, message: "", interrupt: false)
83
+ @rules << { type: :pattern, pattern: to_regexp(pattern), action: :deny, message: message, interrupt: interrupt }
84
+ self
85
+ end
86
+
87
+ # Set a custom handler for tools that don't match any rule.
88
+ #
89
+ # @yield [name, input, context] Called for unmatched tools
90
+ # @return [self]
91
+ def ask(&handler)
92
+ @fallback = handler
93
+ self
94
+ end
95
+
96
+ # Set fallback to allow all unmatched tools.
97
+ # @return [self]
98
+ def allow_all
99
+ @fallback = :allow
100
+ self
101
+ end
102
+
103
+ # Set fallback to deny all unmatched tools.
104
+ #
105
+ # @param message [String] Denial message for unmatched tools
106
+ # @return [self]
107
+ def deny_all(message: "Denied by policy")
108
+ @fallback = { action: :deny, message: message }
109
+ self
110
+ end
111
+
112
+ # Compile this policy into a `can_use_tool` lambda.
113
+ #
114
+ # @return [Proc] Lambda compatible with Options#can_use_tool
115
+ def to_can_use_tool
116
+ rules = @rules.dup.freeze
117
+ fallback = @fallback
118
+
119
+ ->(tool_name, tool_input, context) {
120
+ # Check rules in order, first match wins
121
+ rules.each do |rule|
122
+ next unless matches?(rule, tool_name)
123
+
124
+ case rule[:action]
125
+ when :allow
126
+ return PermissionResultAllow.new
127
+ when :deny
128
+ return PermissionResultDeny.new(message: rule[:message], interrupt: rule[:interrupt])
129
+ end
130
+ end
131
+
132
+ # No rule matched, use fallback
133
+ case fallback
134
+ when :allow
135
+ PermissionResultAllow.new
136
+ when Hash
137
+ PermissionResultDeny.new(message: fallback[:message])
138
+ when Proc
139
+ fallback.call(tool_name, tool_input, context)
140
+ else
141
+ # Default: allow (no policy restriction)
142
+ PermissionResultAllow.new
143
+ end
144
+ }
145
+ end
146
+
147
+ # Whether any rules have been defined.
148
+ # @return [Boolean]
149
+ def empty?
150
+ @rules.empty? && @fallback.nil?
151
+ end
152
+
153
+ private
154
+
155
+ def matches?(rule, tool_name)
156
+ case rule[:type]
157
+ when :exact
158
+ rule[:name] == tool_name.to_s
159
+ when :pattern
160
+ rule[:pattern].match?(tool_name.to_s)
161
+ else
162
+ false
163
+ end
164
+ end
165
+
166
+ def to_regexp(pattern)
167
+ case pattern
168
+ when Regexp then pattern
169
+ when String then Regexp.new(pattern)
170
+ else raise ArgumentError, "Pattern must be a Regexp or String, got #{pattern.class}"
171
+ end
172
+ end
173
+ end
174
+ end