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.
- checksums.yaml +4 -4
- data/.claude/rules/conventions.md +66 -16
- data/CHANGELOG.md +20 -0
- data/CLAUDE.md +24 -4
- data/README.md +52 -1529
- data/SPEC.md +56 -29
- data/docs/architecture.md +339 -0
- data/docs/client.md +526 -0
- data/docs/configuration.md +571 -0
- data/docs/conversations.md +461 -0
- data/docs/errors.md +127 -0
- data/docs/events.md +225 -0
- data/docs/getting-started.md +310 -0
- data/docs/hooks.md +380 -0
- data/docs/logging.md +96 -0
- data/docs/mcp.md +308 -0
- data/docs/messages.md +871 -0
- data/docs/permissions.md +611 -0
- data/docs/queries.md +227 -0
- data/docs/sessions.md +335 -0
- data/lib/claude_agent/abort_controller.rb +24 -0
- data/lib/claude_agent/client/commands.rb +32 -0
- data/lib/claude_agent/client.rb +10 -4
- data/lib/claude_agent/configuration.rb +129 -0
- data/lib/claude_agent/control_protocol/commands.rb +28 -0
- data/lib/claude_agent/conversation.rb +37 -4
- data/lib/claude_agent/errors.rb +21 -4
- data/lib/claude_agent/event_handler.rb +14 -0
- data/lib/claude_agent/fork_session.rb +117 -0
- data/lib/claude_agent/hook_registry.rb +110 -0
- data/lib/claude_agent/hooks.rb +4 -0
- data/lib/claude_agent/mcp/server.rb +22 -0
- data/lib/claude_agent/mcp/tool.rb +24 -3
- data/lib/claude_agent/message.rb +93 -0
- data/lib/claude_agent/messages/streaming.rb +37 -0
- data/lib/claude_agent/options.rb +10 -0
- data/lib/claude_agent/permission_policy.rb +174 -0
- data/lib/claude_agent/permission_request.rb +17 -0
- data/lib/claude_agent/session.rb +100 -11
- data/lib/claude_agent/session_paths.rb +5 -2
- data/lib/claude_agent/turn_result.rb +20 -2
- data/lib/claude_agent/types/sessions.rb +8 -0
- data/lib/claude_agent/version.rb +1 -1
- data/lib/claude_agent.rb +187 -0
- data/sig/claude_agent.rbs +38 -1
- 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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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)
|
data/lib/claude_agent/errors.rb
CHANGED
|
@@ -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
|
-
#
|
|
96
|
-
#
|
|
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
|
-
|
|
100
|
-
|
|
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
|
data/lib/claude_agent/hooks.rb
CHANGED
|
@@ -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" }
|