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,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Base error class for all ClaudeAgent errors
5
+ class Error < StandardError; end
6
+
7
+ # Raised when the Claude Code CLI cannot be found
8
+ class CLINotFoundError < Error
9
+ def initialize(message = "Claude Code CLI not found. Please install it first.")
10
+ super
11
+ end
12
+ end
13
+
14
+ # Raised when the CLI version is below minimum required
15
+ class CLIVersionError < Error
16
+ MINIMUM_VERSION = "2.0.0"
17
+
18
+ def initialize(found_version = nil)
19
+ message = if found_version
20
+ "Claude Code CLI version #{found_version} is below minimum required version #{MINIMUM_VERSION}"
21
+ else
22
+ "Could not determine Claude Code CLI version. Minimum required: #{MINIMUM_VERSION}"
23
+ end
24
+ super(message)
25
+ end
26
+ end
27
+
28
+ # Raised when connection to CLI fails
29
+ class CLIConnectionError < Error
30
+ def initialize(message = "Failed to connect to Claude Code CLI")
31
+ super
32
+ end
33
+ end
34
+
35
+ # Raised when the CLI process exits with an error
36
+ class ProcessError < Error
37
+ attr_reader :exit_code, :stderr
38
+
39
+ def initialize(message = "CLI process failed", exit_code: nil, stderr: nil)
40
+ @exit_code = exit_code
41
+ @stderr = stderr
42
+ full_message = message
43
+ full_message += " (exit code: #{exit_code})" if exit_code
44
+ full_message += "\nStderr: #{stderr}" if stderr && !stderr.empty?
45
+ super(full_message)
46
+ end
47
+ end
48
+
49
+ # Raised when JSON parsing fails
50
+ class JSONDecodeError < Error
51
+ attr_reader :raw_content
52
+
53
+ def initialize(message = "Failed to decode JSON", raw_content: nil)
54
+ @raw_content = raw_content
55
+ full_message = message
56
+ full_message += "\nContent: #{raw_content[0..200]}..." if raw_content
57
+ super(full_message)
58
+ end
59
+ end
60
+
61
+ # Raised when message parsing fails
62
+ class MessageParseError < Error
63
+ attr_reader :raw_message
64
+
65
+ def initialize(message = "Failed to parse message", raw_message: nil)
66
+ @raw_message = raw_message
67
+ full_message = message
68
+ full_message += "\nRaw message: #{raw_message.inspect[0..200]}" if raw_message
69
+ super(full_message)
70
+ end
71
+ end
72
+
73
+ # Raised when a control protocol request times out
74
+ class TimeoutError < Error
75
+ attr_reader :request_id, :timeout_seconds
76
+
77
+ def initialize(message = "Request timed out", request_id: nil, timeout_seconds: nil)
78
+ @request_id = request_id
79
+ @timeout_seconds = timeout_seconds
80
+ full_message = message
81
+ full_message += " (request_id: #{request_id})" if request_id
82
+ full_message += " after #{timeout_seconds}s" if timeout_seconds
83
+ super(full_message)
84
+ end
85
+ end
86
+
87
+ # Raised when an invalid option is provided
88
+ class ConfigurationError < Error; end
89
+
90
+ # Raised when an operation is aborted/cancelled (TypeScript SDK parity)
91
+ #
92
+ # This error is raised when an operation is explicitly cancelled,
93
+ # such as through a user interrupt or abort signal.
94
+ #
95
+ # @example
96
+ # raise ClaudeAgent::AbortError, "Operation cancelled by user"
97
+ #
98
+ class AbortError < Error
99
+ def initialize(message = "Operation was aborted")
100
+ super
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ # Hook events that can be intercepted (TypeScript SDK parity)
5
+ HOOK_EVENTS = %w[
6
+ PreToolUse
7
+ PostToolUse
8
+ PostToolUseFailure
9
+ Notification
10
+ UserPromptSubmit
11
+ SessionStart
12
+ SessionEnd
13
+ Stop
14
+ SubagentStart
15
+ SubagentStop
16
+ PreCompact
17
+ PermissionRequest
18
+ ].freeze
19
+
20
+ # Matcher configuration for hooks
21
+ #
22
+ # @example Basic usage
23
+ # matcher = HookMatcher.new(
24
+ # matcher: "Bash|Write",
25
+ # callbacks: [->(input, context) { {continue_: true} }],
26
+ # timeout: 30
27
+ # )
28
+ #
29
+ HookMatcher = Data.define(:matcher, :callbacks, :timeout) do
30
+ def initialize(matcher:, callbacks:, timeout: nil)
31
+ super
32
+ end
33
+
34
+ # Check if this matcher matches a tool name
35
+ # @param tool_name [String] Tool name to check
36
+ # @return [Boolean]
37
+ def matches?(tool_name)
38
+ case matcher
39
+ when String
40
+ if matcher.include?("|")
41
+ matcher.split("|").any? { |m| tool_name == m }
42
+ else
43
+ Regexp.new(matcher).match?(tool_name)
44
+ end
45
+ when Regexp
46
+ matcher.match?(tool_name)
47
+ else
48
+ true
49
+ end
50
+ end
51
+ end
52
+
53
+ # Context passed to hook callbacks
54
+ #
55
+ HookContext = Data.define(:tool_use_id) do
56
+ def initialize(tool_use_id: nil)
57
+ super
58
+ end
59
+ end
60
+
61
+ # Base class for hook input types (TypeScript SDK parity)
62
+ #
63
+ class BaseHookInput
64
+ attr_reader :hook_event_name, :session_id, :transcript_path, :cwd, :permission_mode
65
+
66
+ def initialize(hook_event_name:, session_id: nil, transcript_path: nil, cwd: nil, permission_mode: nil, **kwargs)
67
+ @hook_event_name = hook_event_name
68
+ @session_id = session_id
69
+ @transcript_path = transcript_path
70
+ @cwd = cwd
71
+ @permission_mode = permission_mode
72
+ end
73
+ end
74
+
75
+ # Input for PreToolUse hook
76
+ #
77
+ class PreToolUseInput < BaseHookInput
78
+ attr_reader :tool_name, :tool_input, :tool_use_id
79
+
80
+ def initialize(tool_name:, tool_input:, tool_use_id: nil, **kwargs)
81
+ super(hook_event_name: "PreToolUse", **kwargs)
82
+ @tool_name = tool_name
83
+ @tool_input = tool_input
84
+ @tool_use_id = tool_use_id
85
+ end
86
+ end
87
+
88
+ # Input for PostToolUse hook
89
+ #
90
+ class PostToolUseInput < BaseHookInput
91
+ attr_reader :tool_name, :tool_input, :tool_response, :tool_use_id
92
+
93
+ def initialize(tool_name:, tool_input:, tool_response:, tool_use_id: nil, **kwargs)
94
+ super(hook_event_name: "PostToolUse", **kwargs)
95
+ @tool_name = tool_name
96
+ @tool_input = tool_input
97
+ @tool_response = tool_response
98
+ @tool_use_id = tool_use_id
99
+ end
100
+ end
101
+
102
+ # Input for PostToolUseFailure hook (TypeScript SDK parity)
103
+ #
104
+ class PostToolUseFailureInput < BaseHookInput
105
+ attr_reader :tool_name, :tool_input, :tool_use_id, :error, :is_interrupt
106
+
107
+ def initialize(tool_name:, tool_input:, error:, tool_use_id: nil, is_interrupt: nil, **kwargs)
108
+ super(hook_event_name: "PostToolUseFailure", **kwargs)
109
+ @tool_name = tool_name
110
+ @tool_input = tool_input
111
+ @tool_use_id = tool_use_id
112
+ @error = error
113
+ @is_interrupt = is_interrupt
114
+ end
115
+ end
116
+
117
+ # Input for Notification hook (TypeScript SDK parity)
118
+ #
119
+ class NotificationInput < BaseHookInput
120
+ attr_reader :message, :title, :notification_type
121
+
122
+ def initialize(message:, title: nil, notification_type: nil, **kwargs)
123
+ super(hook_event_name: "Notification", **kwargs)
124
+ @message = message
125
+ @title = title
126
+ @notification_type = notification_type
127
+ end
128
+ end
129
+
130
+ # Input for UserPromptSubmit hook
131
+ #
132
+ class UserPromptSubmitInput < BaseHookInput
133
+ attr_reader :prompt
134
+
135
+ def initialize(prompt:, **kwargs)
136
+ super(hook_event_name: "UserPromptSubmit", **kwargs)
137
+ @prompt = prompt
138
+ end
139
+ end
140
+
141
+ # Input for SessionStart hook (TypeScript SDK parity)
142
+ #
143
+ class SessionStartInput < BaseHookInput
144
+ attr_reader :source, :agent_type
145
+
146
+ # @param source [String] One of: "startup", "resume", "clear", "compact"
147
+ # @param agent_type [String, nil] Type of agent if running in subagent context
148
+ def initialize(source:, agent_type: nil, **kwargs)
149
+ super(hook_event_name: "SessionStart", **kwargs)
150
+ @source = source
151
+ @agent_type = agent_type
152
+ end
153
+ end
154
+
155
+ # Input for SessionEnd hook (TypeScript SDK parity)
156
+ #
157
+ class SessionEndInput < BaseHookInput
158
+ attr_reader :reason
159
+
160
+ def initialize(reason:, **kwargs)
161
+ super(hook_event_name: "SessionEnd", **kwargs)
162
+ @reason = reason
163
+ end
164
+ end
165
+
166
+ # Input for Stop hook
167
+ #
168
+ class StopInput < BaseHookInput
169
+ attr_reader :stop_hook_active
170
+
171
+ def initialize(stop_hook_active: false, **kwargs)
172
+ super(hook_event_name: "Stop", **kwargs)
173
+ @stop_hook_active = stop_hook_active
174
+ end
175
+ end
176
+
177
+ # Input for SubagentStart hook (TypeScript SDK parity)
178
+ #
179
+ class SubagentStartInput < BaseHookInput
180
+ attr_reader :agent_id, :agent_type
181
+
182
+ def initialize(agent_id:, agent_type:, **kwargs)
183
+ super(hook_event_name: "SubagentStart", **kwargs)
184
+ @agent_id = agent_id
185
+ @agent_type = agent_type
186
+ end
187
+ end
188
+
189
+ # Input for SubagentStop hook
190
+ #
191
+ class SubagentStopInput < BaseHookInput
192
+ attr_reader :stop_hook_active, :agent_id, :agent_transcript_path
193
+
194
+ def initialize(stop_hook_active: false, agent_id: nil, agent_transcript_path: nil, **kwargs)
195
+ super(hook_event_name: "SubagentStop", **kwargs)
196
+ @stop_hook_active = stop_hook_active
197
+ @agent_id = agent_id
198
+ @agent_transcript_path = agent_transcript_path
199
+ end
200
+ end
201
+
202
+ # Input for PreCompact hook
203
+ #
204
+ class PreCompactInput < BaseHookInput
205
+ attr_reader :trigger, :custom_instructions
206
+
207
+ # @param trigger [String] One of: "manual", "auto"
208
+ # @param custom_instructions [String, nil] Custom instructions for compaction
209
+ def initialize(trigger:, custom_instructions: nil, **kwargs)
210
+ super(hook_event_name: "PreCompact", **kwargs)
211
+ @trigger = trigger
212
+ @custom_instructions = custom_instructions
213
+ end
214
+ end
215
+
216
+ # Input for PermissionRequest hook (TypeScript SDK parity)
217
+ #
218
+ class PermissionRequestInput < BaseHookInput
219
+ attr_reader :tool_name, :tool_input, :permission_suggestions
220
+
221
+ def initialize(tool_name:, tool_input:, permission_suggestions: nil, **kwargs)
222
+ super(hook_event_name: "PermissionRequest", **kwargs)
223
+ @tool_name = tool_name
224
+ @tool_input = tool_input
225
+ @permission_suggestions = permission_suggestions
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ClaudeAgent
6
+ module MCP
7
+ # In-process MCP server for hosting tools
8
+ #
9
+ # Unlike external MCP servers that run as subprocesses, SDK servers
10
+ # run in the same Ruby process, providing better performance and
11
+ # easier debugging.
12
+ #
13
+ # @example Create a server with tools
14
+ # add_tool = ClaudeAgent::MCP::Tool.new(
15
+ # name: "add",
16
+ # description: "Add two numbers",
17
+ # schema: {a: Float, b: Float}
18
+ # ) { |args| args["a"] + args["b"] }
19
+ #
20
+ # server = ClaudeAgent::MCP::Server.new(
21
+ # name: "calculator",
22
+ # tools: [add_tool]
23
+ # )
24
+ #
25
+ # @example Use with ClaudeAgent
26
+ # options = ClaudeAgent::Options.new(
27
+ # mcp_servers: {
28
+ # "calculator" => {type: "sdk", instance: server}
29
+ # }
30
+ # )
31
+ #
32
+ class Server
33
+ attr_reader :name, :tools
34
+
35
+ # @param name [String] Server name
36
+ # @param tools [Array<Tool>] Tools to expose
37
+ def initialize(name:, tools: [])
38
+ @name = name.to_s
39
+ @tools = {}
40
+ tools.each { |tool| add_tool(tool) }
41
+ end
42
+
43
+ # Add a tool to the server
44
+ # @param tool [Tool] Tool to add
45
+ # @return [void]
46
+ def add_tool(tool)
47
+ @tools[tool.name] = tool
48
+ end
49
+
50
+ # Remove a tool from the server
51
+ # @param name [String] Tool name
52
+ # @return [Tool, nil] Removed tool
53
+ def remove_tool(name)
54
+ @tools.delete(name.to_s)
55
+ end
56
+
57
+ # Handle an MCP message
58
+ # @param message [Hash] MCP JSON-RPC message
59
+ # @return [Hash] MCP JSON-RPC response
60
+ def handle_message(message)
61
+ method = message["method"]
62
+ params = message["params"] || {}
63
+ id = message["id"]
64
+
65
+ result = case method
66
+ when "initialize"
67
+ handle_initialize(params)
68
+ when "tools/list"
69
+ handle_tools_list(params)
70
+ when "tools/call"
71
+ handle_tools_call(params)
72
+ when "notifications/initialized"
73
+ # Acknowledgement, no response needed
74
+ return nil
75
+ else
76
+ return jsonrpc_error(id, -32601, "Method not found: #{method}")
77
+ end
78
+
79
+ jsonrpc_response(id, result)
80
+ rescue => e
81
+ jsonrpc_error(id, -32603, "Internal error: #{e.message}")
82
+ end
83
+
84
+ # Get MCP server configuration for options
85
+ # @return [Hash]
86
+ def to_config
87
+ { type: "sdk", name: @name, instance: self }
88
+ end
89
+
90
+ private
91
+
92
+ def handle_initialize(params)
93
+ {
94
+ protocolVersion: "2024-11-05",
95
+ capabilities: {
96
+ tools: {}
97
+ },
98
+ serverInfo: {
99
+ name: @name,
100
+ version: ClaudeAgent::VERSION
101
+ }
102
+ }
103
+ end
104
+
105
+ def handle_tools_list(params)
106
+ {
107
+ tools: @tools.values.map(&:to_mcp_definition)
108
+ }
109
+ end
110
+
111
+ def handle_tools_call(params)
112
+ tool_name = params["name"]
113
+ arguments = params["arguments"] || {}
114
+
115
+ tool = @tools[tool_name]
116
+ unless tool
117
+ return {
118
+ content: [ { type: "text", text: "Unknown tool: #{tool_name}" } ],
119
+ isError: true
120
+ }
121
+ end
122
+
123
+ tool.call(arguments)
124
+ end
125
+
126
+ def jsonrpc_response(id, result)
127
+ {
128
+ jsonrpc: "2.0",
129
+ id: id,
130
+ result: result
131
+ }
132
+ end
133
+
134
+ def jsonrpc_error(id, code, message)
135
+ {
136
+ jsonrpc: "2.0",
137
+ id: id,
138
+ error: {
139
+ code: code,
140
+ message: message
141
+ }
142
+ }
143
+ end
144
+ end
145
+
146
+ # Convenience method to create a tool
147
+ #
148
+ # @example
149
+ # tool = ClaudeAgent::MCP.tool("greet", "Greet someone", {name: String}) do |args|
150
+ # "Hello, #{args['name']}!"
151
+ # end
152
+ #
153
+ def self.tool(name, description, schema = {}, &handler)
154
+ Tool.new(name: name, description: description, schema: schema, &handler)
155
+ end
156
+
157
+ # Convenience method to create a server
158
+ #
159
+ # @example
160
+ # server = ClaudeAgent::MCP.create_server(name: "mytools", tools: [tool1, tool2])
161
+ #
162
+ def self.create_server(name:, tools: [])
163
+ Server.new(name: name, tools: tools)
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgent
4
+ module MCP
5
+ # Defines a tool that can be called by Claude
6
+ #
7
+ # @example Simple tool
8
+ # tool = ClaudeAgent::MCP::Tool.new(
9
+ # name: "greet",
10
+ # description: "Greet a person",
11
+ # schema: {name: String}
12
+ # ) do |args|
13
+ # "Hello, #{args['name']}!"
14
+ # end
15
+ #
16
+ # @example Tool with complex schema
17
+ # tool = ClaudeAgent::MCP::Tool.new(
18
+ # name: "calculate",
19
+ # description: "Perform arithmetic",
20
+ # schema: {
21
+ # type: "object",
22
+ # properties: {
23
+ # operation: {type: "string", enum: ["add", "subtract"]},
24
+ # a: {type: "number"},
25
+ # b: {type: "number"}
26
+ # },
27
+ # required: ["operation", "a", "b"]
28
+ # }
29
+ # ) do |args|
30
+ # case args["operation"]
31
+ # when "add" then args["a"] + args["b"]
32
+ # when "subtract" then args["a"] - args["b"]
33
+ # end
34
+ # end
35
+ #
36
+ class Tool
37
+ attr_reader :name, :description, :schema, :handler
38
+
39
+ # @param name [String] Tool name (must be unique within server)
40
+ # @param description [String] Description of what the tool does
41
+ # @param schema [Hash] Input schema (simple Ruby types or JSON Schema)
42
+ # @param handler [Proc] Block to execute when tool is called
43
+ def initialize(name:, description:, schema: {}, &handler)
44
+ @name = name.to_s
45
+ @description = description.to_s
46
+ @schema = normalize_schema(schema)
47
+ @handler = handler || ->(args) { raise "No handler defined for tool #{name}" }
48
+ end
49
+
50
+ # Call the tool with arguments
51
+ # @param args [Hash] Tool arguments
52
+ # @return [Hash] MCP response format
53
+ def call(args)
54
+ result = @handler.call(args)
55
+ format_result(result)
56
+ rescue => e
57
+ format_error(e)
58
+ end
59
+
60
+ # Convert to MCP tool definition format
61
+ # @return [Hash]
62
+ def to_mcp_definition
63
+ {
64
+ name: @name,
65
+ description: @description,
66
+ inputSchema: @schema
67
+ }
68
+ end
69
+
70
+ private
71
+
72
+ # Normalize schema from simple Ruby types to JSON Schema
73
+ def normalize_schema(schema)
74
+ return schema if json_schema?(schema)
75
+
76
+ # Convert simple {name: Type} format to JSON Schema
77
+ if schema.is_a?(Hash) && schema.values.all? { |v| v.is_a?(Class) || v.is_a?(Module) }
78
+ {
79
+ type: "object",
80
+ properties: schema.transform_values { |type| type_to_schema(type) },
81
+ required: schema.keys.map(&:to_s)
82
+ }
83
+ else
84
+ schema
85
+ end
86
+ end
87
+
88
+ def json_schema?(schema)
89
+ return false unless schema.is_a?(Hash)
90
+
91
+ schema.key?(:type) || schema.key?("type") ||
92
+ schema.key?(:properties) || schema.key?("properties")
93
+ end
94
+
95
+ def type_to_schema(type)
96
+ case type.to_s
97
+ when "String"
98
+ { type: "string" }
99
+ when "Integer"
100
+ { type: "integer" }
101
+ when "Float", "Numeric"
102
+ { type: "number" }
103
+ when "TrueClass", "FalseClass"
104
+ { type: "boolean" }
105
+ when "Array"
106
+ { type: "array" }
107
+ when "Hash"
108
+ { type: "object" }
109
+ else
110
+ { type: "string" }
111
+ end
112
+ end
113
+
114
+ def format_result(result)
115
+ content = case result
116
+ when String
117
+ [ { type: "text", text: result } ]
118
+ when Hash
119
+ result[:content] || [ { type: "text", text: result.to_json } ]
120
+ when Array
121
+ result
122
+ else
123
+ [ { type: "text", text: result.to_s } ]
124
+ end
125
+
126
+ { content: content, isError: false }
127
+ end
128
+
129
+ def format_error(error)
130
+ {
131
+ content: [ { type: "text", text: "Error: #{error.message}" } ],
132
+ isError: true
133
+ }
134
+ end
135
+ end
136
+ end
137
+ end