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,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
@@ -24,6 +24,43 @@ module ClaudeAgent
24
24
  def event_type
25
25
  event[:type]
26
26
  end
27
+
28
+ # Text delta content, or nil if this is not a text_delta event.
29
+ # @return [String, nil]
30
+ def delta_text
31
+ return nil unless event_type == "content_block_delta"
32
+ delta = event[:delta] || event["delta"]
33
+ return nil unless delta
34
+ type = delta[:type] || delta["type"]
35
+ return nil unless type == "text_delta"
36
+ delta[:text] || delta["text"]
37
+ end
38
+
39
+ # The delta type (e.g. "text_delta", "thinking_delta", "input_json_delta").
40
+ # @return [String, nil]
41
+ def delta_type
42
+ return nil unless event_type == "content_block_delta"
43
+ delta = event[:delta] || event["delta"]
44
+ return nil unless delta
45
+ delta[:type] || delta["type"]
46
+ end
47
+
48
+ # Thinking delta text, or nil if this is not a thinking_delta event.
49
+ # @return [String, nil]
50
+ def thinking_text
51
+ return nil unless event_type == "content_block_delta"
52
+ delta = event[:delta] || event["delta"]
53
+ return nil unless delta
54
+ type = delta[:type] || delta["type"]
55
+ return nil unless type == "thinking_delta"
56
+ delta[:thinking] || delta["thinking"]
57
+ end
58
+
59
+ # Content block index within the message.
60
+ # @return [Integer, nil]
61
+ def content_index
62
+ event[:index] || event["index"]
63
+ end
27
64
  end
28
65
 
29
66
  # Rate limit event (TypeScript SDK v0.2.45 parity)
@@ -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
@@ -132,6 +132,23 @@ module ClaudeAgent
132
132
  end
133
133
  end
134
134
 
135
+ # Human-readable label for the permission request.
136
+ #
137
+ # Delegates to {ToolUseBlock#display_label} formatting.
138
+ #
139
+ # @return [String]
140
+ def display_label
141
+ ToolUseBlock.new(id: "", name: tool_name, input: input || {}).display_label
142
+ end
143
+
144
+ # Detailed summary of the tool call.
145
+ #
146
+ # @param max [Integer] Maximum length before truncation
147
+ # @return [String]
148
+ def summary(max: 60)
149
+ ToolUseBlock.new(id: "", name: tool_name, input: input || {}).summary(max: max)
150
+ end
151
+
135
152
  def inspect
136
153
  status = resolved? ? "resolved(#{@result&.behavior})" : "pending"
137
154
  "#<#{self.class} tool=#{tool_name} status=#{status} age=#{(Time.now - created_at).round(1)}s>"
@@ -1,24 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgent
4
- # Historical session finder with Rails-like API.
4
+ # Historical session finder with Rails-like API and Stripe-style resource methods.
5
5
  #
6
- # Wraps SessionInfo with a rich interface for finding sessions
7
- # and querying their message transcripts.
6
+ # Wraps SessionInfo with a rich interface for finding sessions,
7
+ # querying their message transcripts, and mutating session metadata.
8
8
  #
9
9
  # @example Find a session by ID
10
10
  # session = ClaudeAgent::Session.find("abc-123")
11
11
  # session.summary # => "Fix login bug"
12
12
  #
13
- # @example List all sessions
14
- # sessions = ClaudeAgent::Session.all(limit: 10)
13
+ # @example Retrieve (raises on not found)
14
+ # session = ClaudeAgent::Session.retrieve("abc-123")
15
15
  #
16
- # @example Query messages with chainable relation
17
- # session.messages.where(limit: 5).each { |m| puts m.type }
16
+ # @example Mutations
17
+ # session.rename("My Session")
18
+ # session.tag("important")
19
+ # forked = session.fork(title: "Branch off")
20
+ #
21
+ # @example Resume a conversation
22
+ # session.resume(model: "opus") { |c| c.say("Continue") }
18
23
  #
19
24
  class Session
20
25
  attr_reader :session_id, :summary, :last_modified, :file_size,
21
- :custom_title, :first_prompt, :git_branch, :cwd
26
+ :custom_title, :first_prompt, :git_branch, :cwd,
27
+ :tag, :created_at
22
28
 
23
29
  def initialize(session_info)
24
30
  @session_id = session_info.session_id
@@ -29,6 +35,8 @@ module ClaudeAgent
29
35
  @first_prompt = session_info.first_prompt
30
36
  @git_branch = session_info.git_branch
31
37
  @cwd = session_info.cwd
38
+ @tag = session_info.tag
39
+ @created_at = session_info.created_at
32
40
  @dir = session_info.cwd
33
41
  end
34
42
 
@@ -39,18 +47,99 @@ module ClaudeAgent
39
47
  SessionMessageRelation.new(session_id, dir: @dir)
40
48
  end
41
49
 
50
+ # --- Instance Mutation Methods ---
51
+
52
+ # Rename this session.
53
+ #
54
+ # @param title [String] New title
55
+ # @return [self]
56
+ def rename(title)
57
+ SessionMutations.rename_session(session_id, title, dir: @dir)
58
+ @custom_title = title
59
+ self
60
+ end
61
+
62
+ # Tag this session.
63
+ #
64
+ # @param value [String, nil] Tag value. Pass nil to clear.
65
+ # @return [self]
66
+ def tag_session(value)
67
+ SessionMutations.tag_session(session_id, value, dir: @dir)
68
+ @tag = value
69
+ self
70
+ end
71
+
72
+ # Fork this session.
73
+ #
74
+ # @param up_to [String, nil] Truncate at this message UUID (inclusive)
75
+ # @param title [String, nil] Title for the forked session
76
+ # @return [Session] The new forked session
77
+ def fork(up_to: nil, title: nil)
78
+ result = ForkSession.call(session_id, up_to_message_id: up_to, title: title, dir: @dir)
79
+ info = GetSessionInfo.call(result.session_id, dir: @dir)
80
+ info ? Session.new(info) : Session.new(SessionInfo.new(
81
+ session_id: result.session_id,
82
+ summary: title || @summary,
83
+ last_modified: Time.now.to_i * 1000,
84
+ file_size: 0
85
+ ))
86
+ end
87
+
88
+ # Re-read session metadata from disk.
89
+ #
90
+ # @return [self]
91
+ def reload
92
+ info = GetSessionInfo.call(session_id, dir: @dir)
93
+ raise NotFoundError, "Session not found: #{session_id}" unless info
94
+
95
+ @summary = info.summary
96
+ @last_modified = info.last_modified
97
+ @file_size = info.file_size
98
+ @custom_title = info.custom_title
99
+ @first_prompt = info.first_prompt
100
+ @git_branch = info.git_branch
101
+ @cwd = info.cwd
102
+ @tag = info.tag
103
+ @created_at = info.created_at
104
+ self
105
+ end
106
+
107
+ # Resume this session as a Conversation.
108
+ #
109
+ # @param kwargs Options/Conversation keyword arguments
110
+ # @yield [Conversation] Block form with auto-cleanup
111
+ # @return [Conversation, Object]
112
+ def resume(**kwargs, &block)
113
+ if block
114
+ Conversation.open(resume: session_id, **kwargs, &block)
115
+ else
116
+ Conversation.resume(session_id, **kwargs)
117
+ end
118
+ end
119
+
42
120
  class << self
43
- # Find a session by its UUID.
121
+ # Find a session by its UUID (targeted lookup, not full scan).
44
122
  #
45
123
  # @param session_id [String] UUID of the session
46
124
  # @param dir [String, nil] Directory to scope the search
47
125
  # @return [Session, nil] The session, or nil if not found
48
126
  def find(session_id, dir: nil)
49
- sessions = ListSessions.call(dir: dir)
50
- info = sessions.find { |s| s.session_id == session_id }
127
+ info = GetSessionInfo.call(session_id, dir: dir)
51
128
  info ? new(info) : nil
52
129
  end
53
130
 
131
+ # Retrieve a session by UUID. Raises if not found (Stripe convention).
132
+ #
133
+ # @param session_id [String] UUID of the session
134
+ # @param dir [String, nil] Directory to scope the search
135
+ # @return [Session]
136
+ # @raise [NotFoundError] If session is not found
137
+ def retrieve(session_id, dir: nil)
138
+ info = GetSessionInfo.call(session_id, dir: dir)
139
+ raise NotFoundError, "Session not found: #{session_id}" unless info
140
+ new(info)
141
+ end
142
+
54
143
  # List all sessions.
55
144
  #
56
145
  # @return [Array<Session>]
@@ -93,9 +93,12 @@ module ClaudeAgent
93
93
  # @param path [String]
94
94
  # @return [String]
95
95
  def realpath(path)
96
- File.realpath(path).unicode_normalize(:nfc)
96
+ resolved = File.realpath(path)
97
+ resolved = resolved.encode("UTF-8") unless resolved.encoding == Encoding::UTF_8
98
+ resolved.unicode_normalize(:nfc)
97
99
  rescue SystemCallError
98
- path.unicode_normalize(:nfc)
100
+ safe = path.encode("UTF-8") rescue path
101
+ safe.unicode_normalize(:nfc) rescue safe
99
102
  end
100
103
 
101
104
  # Get git worktree paths for a directory.
@@ -35,6 +35,7 @@ module ClaudeAgent
35
35
  def initialize
36
36
  @messages = []
37
37
  @result = nil
38
+ @streamed_text = +""
38
39
  end
39
40
 
40
41
  # Append a message to this turn
@@ -44,6 +45,13 @@ module ClaudeAgent
44
45
  def <<(message)
45
46
  @messages << message
46
47
  @result = message if message.is_a?(ResultMessage)
48
+
49
+ # Accumulate streaming text deltas for reliable text access on abort
50
+ if message.is_a?(StreamEvent)
51
+ delta = message.delta_text
52
+ @streamed_text << delta if delta
53
+ end
54
+
47
55
  self
48
56
  end
49
57
 
@@ -61,10 +69,20 @@ module ClaudeAgent
61
69
 
62
70
  # --- Text & Thinking ---
63
71
 
64
- # All text content concatenated across assistant messages
72
+ # All text content from the turn.
73
+ #
74
+ # Returns text from AssistantMessages when available (canonical source).
75
+ # Falls back to accumulated streaming deltas when assistant messages
76
+ # have no text (e.g., turn was aborted before AssistantMessage arrived).
77
+ #
65
78
  # @return [String]
66
79
  def text
67
- assistant_messages.map(&:text).join
80
+ t = assistant_messages.map(&:text).join
81
+ if t.empty? && !@streamed_text.empty?
82
+ @streamed_text.dup.freeze
83
+ else
84
+ t
85
+ end
68
86
  end
69
87
 
70
88
  # All thinking content concatenated across assistant messages
@@ -47,4 +47,12 @@ module ClaudeAgent
47
47
  super
48
48
  end
49
49
  end
50
+
51
+ # Result of forking a session (TypeScript SDK v0.2.76 parity)
52
+ #
53
+ # @example
54
+ # result = ClaudeAgent.fork_session("abc-123")
55
+ # puts result.session_id # => new UUID
56
+ #
57
+ ForkSessionResult = Data.define(:session_id)
50
58
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgent
4
- VERSION = "0.7.14"
4
+ VERSION = "0.7.16"
5
5
  end