claude_agent 0.7.14 → 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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/rules/conventions.md +66 -16
  3. data/CHANGELOG.md +20 -0
  4. data/CLAUDE.md +24 -4
  5. data/README.md +52 -1529
  6. data/SPEC.md +56 -29
  7. data/docs/architecture.md +339 -0
  8. data/docs/client.md +526 -0
  9. data/docs/configuration.md +571 -0
  10. data/docs/conversations.md +461 -0
  11. data/docs/errors.md +127 -0
  12. data/docs/events.md +225 -0
  13. data/docs/getting-started.md +310 -0
  14. data/docs/hooks.md +380 -0
  15. data/docs/logging.md +96 -0
  16. data/docs/mcp.md +308 -0
  17. data/docs/messages.md +871 -0
  18. data/docs/permissions.md +611 -0
  19. data/docs/queries.md +227 -0
  20. data/docs/sessions.md +335 -0
  21. data/lib/claude_agent/abort_controller.rb +24 -0
  22. data/lib/claude_agent/client/commands.rb +32 -0
  23. data/lib/claude_agent/client.rb +10 -4
  24. data/lib/claude_agent/configuration.rb +129 -0
  25. data/lib/claude_agent/control_protocol/commands.rb +28 -0
  26. data/lib/claude_agent/conversation.rb +37 -4
  27. data/lib/claude_agent/errors.rb +21 -4
  28. data/lib/claude_agent/event_handler.rb +14 -0
  29. data/lib/claude_agent/fork_session.rb +117 -0
  30. data/lib/claude_agent/hook_registry.rb +110 -0
  31. data/lib/claude_agent/hooks.rb +4 -0
  32. data/lib/claude_agent/mcp/server.rb +22 -0
  33. data/lib/claude_agent/mcp/tool.rb +24 -3
  34. data/lib/claude_agent/message.rb +93 -0
  35. data/lib/claude_agent/messages/streaming.rb +37 -0
  36. data/lib/claude_agent/options.rb +10 -0
  37. data/lib/claude_agent/permission_policy.rb +174 -0
  38. data/lib/claude_agent/permission_request.rb +17 -0
  39. data/lib/claude_agent/session.rb +100 -11
  40. data/lib/claude_agent/session_paths.rb +5 -2
  41. data/lib/claude_agent/turn_result.rb +20 -2
  42. data/lib/claude_agent/types/sessions.rb +8 -0
  43. data/lib/claude_agent/version.rb +1 -1
  44. data/lib/claude_agent.rb +187 -0
  45. data/sig/claude_agent.rbs +38 -1
  46. metadata +20 -1
@@ -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
@@ -299,6 +299,34 @@ module ClaudeAgent
299
299
  errors: response["errors"] || {}
300
300
  )
301
301
  end
302
+ # Cancel a queued async user message (TypeScript SDK v0.2.76 parity)
303
+ #
304
+ # Drops a previously queued user message before it is processed.
305
+ #
306
+ # @param message_uuid [String] UUID of the message to cancel
307
+ # @return [Hash] Response from the CLI
308
+ #
309
+ # @example
310
+ # protocol.cancel_async_message("msg-uuid-123")
311
+ #
312
+ def cancel_async_message(message_uuid)
313
+ send_control_request(subtype: "cancel_async_message", message_uuid: message_uuid)
314
+ end
315
+
316
+ # Get effective merged settings (TypeScript SDK v0.2.76 parity)
317
+ #
318
+ # Returns the current effective settings after merging all layers
319
+ # (user, project, local, flag, etc.).
320
+ #
321
+ # @return [Hash] Merged settings
322
+ #
323
+ # @example
324
+ # settings = protocol.get_settings
325
+ # puts settings["model"]
326
+ #
327
+ def get_settings
328
+ send_control_request(subtype: "get_settings")
329
+ end
302
330
  end
303
331
  end
304
332
  end
@@ -100,12 +100,21 @@ module ClaudeAgent
100
100
  # Send a message and receive the complete turn result.
101
101
  #
102
102
  # Auto-connects on first call. Appends to conversation history.
103
+ # Resets the tool tracker and abort signal at the start of each turn
104
+ # so they are fresh for the new operation.
105
+ #
106
+ # On abort, raises {AbortError} with the partial {TurnResult} attached.
107
+ # The tool tracker retains its state from the aborted turn until the
108
+ # next call to {#say}.
103
109
  #
104
110
  # @param prompt [String, Array] The message content
105
111
  # @yield [Message] Each message as it streams in (optional)
106
112
  # @return [TurnResult] The completed turn
113
+ # @raise [AbortError] If abort signal is triggered (with partial_turn attached)
107
114
  def say(prompt, &block)
108
115
  ensure_connected!
116
+ @tool_tracker&.reset!
117
+ @options.abort_signal&.reset!
109
118
 
110
119
  logger.debug("conversation") { "Turn #{@turns.size}: sending message" }
111
120
 
@@ -116,7 +125,6 @@ module ClaudeAgent
116
125
 
117
126
  @turns << turn
118
127
  build_tool_activities(turn, @turns.size - 1)
119
- @tool_tracker&.reset!
120
128
 
121
129
  logger.info("conversation") { "Turn #{@turns.size - 1} complete (#{turn.tool_uses.size} tools, cost=$#{total_cost})" }
122
130
 
@@ -218,13 +226,38 @@ module ClaudeAgent
218
226
  [ callbacks, conversation_kwargs, options_kwargs ]
219
227
  end
220
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
+
221
238
  def build_options(options_kwargs, conversation_kwargs)
222
239
  permission = conversation_kwargs[:on_permission]
223
240
 
224
- if permission.respond_to?(:call)
225
- options_kwargs[:can_use_tool] = permission
226
- 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
227
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
228
261
  end
229
262
 
230
263
  Options.new(**options_kwargs)
@@ -87,17 +87,34 @@ 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,
93
96
  # such as through a user interrupt or abort signal.
94
97
  #
95
- # @example
96
- # raise ClaudeAgent::AbortError, "Operation cancelled by user"
98
+ # When raised from {Client#receive_turn} or {Conversation#say},
99
+ # carries a partial {TurnResult} with whatever was accumulated
100
+ # before cancellation (text, tool executions, usage).
101
+ #
102
+ # @example Accessing partial results
103
+ # begin
104
+ # turn = conversation.say("Fix the bug")
105
+ # rescue ClaudeAgent::AbortError => e
106
+ # turn = e.partial_turn
107
+ # puts turn.text # accumulated text (from stream events if needed)
108
+ # puts turn.tool_uses # tools that were called before abort
109
+ # end
97
110
  #
98
111
  class AbortError < Error
99
- def initialize(message = "Operation was aborted")
100
- super
112
+ # @return [TurnResult, nil] Partial turn accumulated before abort
113
+ attr_reader :partial_turn
114
+
115
+ def initialize(message = "Operation was aborted", partial_turn: nil)
116
+ @partial_turn = partial_turn
117
+ super(message)
101
118
  end
102
119
  end
103
120
  end
@@ -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,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module ClaudeAgent
7
+ # Fork a session by creating a new session file with remapped UUIDs.
8
+ #
9
+ # Reads all JSONL entries from the source session, optionally truncates
10
+ # at a given message UUID, remaps all UUIDs to fresh values, and writes
11
+ # a new session file in the same project directory.
12
+ #
13
+ # @example Basic fork
14
+ # result = ClaudeAgent.fork_session("abc-123-...")
15
+ # puts result.session_id
16
+ #
17
+ # @example Fork with truncation and title
18
+ # result = ClaudeAgent.fork_session(
19
+ # "abc-123-...",
20
+ # up_to_message_id: "msg-456-...",
21
+ # title: "Forked conversation"
22
+ # )
23
+ #
24
+ module ForkSession
25
+ UUID_FIELDS = %w[uuid parentUuid].freeze
26
+ SESSION_ID_FIELDS = %w[session_id sessionId].freeze
27
+
28
+ module_function
29
+
30
+ # Fork a session, creating a new session file with remapped UUIDs.
31
+ #
32
+ # @param session_id [String] UUID of the source session
33
+ # @param up_to_message_id [String, nil] If provided, truncate at this message UUID (inclusive)
34
+ # @param title [String, nil] If provided, append a custom-title entry
35
+ # @param dir [String, nil] Project directory to find the session in
36
+ # @return [ForkSessionResult] Result with the new session ID
37
+ # @raise [ArgumentError] If session_id is not a valid UUID
38
+ # @raise [Error] If the session file is not found
39
+ def call(session_id, up_to_message_id: nil, title: nil, dir: nil)
40
+ SessionMutations.send(:validate_session_id!, session_id)
41
+
42
+ source_path = SessionMutations.send(:find_session_file, session_id, dir: dir)
43
+ raise Error, "Session not found: #{session_id}" unless source_path
44
+
45
+ lines = File.readlines(source_path, chomp: true)
46
+ entries = lines.filter_map do |line|
47
+ next if line.strip.empty?
48
+ JSON.parse(line)
49
+ end
50
+
51
+ # Truncate at up_to_message_id (inclusive)
52
+ if up_to_message_id
53
+ cut_index = entries.index { |e| e["uuid"] == up_to_message_id }
54
+ raise ArgumentError, "Message not found: #{up_to_message_id}" unless cut_index
55
+ entries = entries[0..cut_index]
56
+ end
57
+
58
+ # Generate new session ID and UUID map
59
+ new_session_id = SecureRandom.uuid
60
+ uuid_map = build_uuid_map(entries)
61
+
62
+ # Remap all entries
63
+ remapped = entries.map { |entry| remap_entry(entry, uuid_map, new_session_id) }
64
+
65
+ # Append custom-title entry if requested
66
+ if title
67
+ remapped << {
68
+ "type" => "custom-title",
69
+ "customTitle" => title.to_s,
70
+ "sessionId" => new_session_id
71
+ }
72
+ end
73
+
74
+ # Write new session file in the same directory as the source
75
+ dest_path = File.join(File.dirname(source_path), "#{new_session_id}.jsonl")
76
+ File.open(dest_path, "w") do |f|
77
+ remapped.each { |entry| f.write("#{JSON.generate(entry)}\n") }
78
+ end
79
+
80
+ ForkSessionResult.new(session_id: new_session_id)
81
+ end
82
+
83
+ # Build a map of old UUIDs to new UUIDs from all entries.
84
+ # @param entries [Array<Hash>]
85
+ # @return [Hash<String, String>]
86
+ def build_uuid_map(entries)
87
+ map = {}
88
+ entries.each do |entry|
89
+ UUID_FIELDS.each do |field|
90
+ value = entry[field]
91
+ next unless value.is_a?(String) && !value.empty?
92
+ map[value] ||= SecureRandom.uuid
93
+ end
94
+ end
95
+ map
96
+ end
97
+
98
+ # Remap UUID and session_id fields in an entry.
99
+ # @param entry [Hash] Original entry
100
+ # @param uuid_map [Hash<String, String>] Old UUID to new UUID mapping
101
+ # @param new_session_id [String] New session ID
102
+ # @return [Hash] Remapped entry
103
+ def remap_entry(entry, uuid_map, new_session_id)
104
+ result = entry.dup
105
+
106
+ UUID_FIELDS.each do |field|
107
+ result[field] = uuid_map[result[field]] if result.key?(field) && uuid_map.key?(result[field])
108
+ end
109
+
110
+ SESSION_ID_FIELDS.each do |field|
111
+ result[field] = new_session_id if result.key?(field)
112
+ end
113
+
114
+ result
115
+ end
116
+ end
117
+ end
@@ -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
@@ -14,6 +14,7 @@ module ClaudeAgent
14
14
  SubagentStart
15
15
  SubagentStop
16
16
  PreCompact
17
+ PostCompact
17
18
  PermissionRequest
18
19
  Setup
19
20
  TeammateIdle
@@ -184,6 +185,9 @@ module ClaudeAgent
184
185
  required: [ :trigger ],
185
186
  optional: { custom_instructions: nil }
186
187
 
188
+ BaseHookInput.define_input "PostCompact",
189
+ required: [ :trigger, :compact_summary ]
190
+
187
191
  BaseHookInput.define_input "PermissionRequest",
188
192
  required: [ :tool_name, :tool_input ],
189
193
  optional: { permission_suggestions: nil }
@@ -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" }