claude-agent-server 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,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'claude_agent_sdk'
4
+
5
+ module ClaudeAgentServer
6
+ module Services
7
+ module MessageSerializer
8
+ module_function
9
+
10
+ def serialize(message)
11
+ case message
12
+ when ClaudeAgentSDK::UserMessage
13
+ serialize_user_message(message)
14
+ when ClaudeAgentSDK::AssistantMessage
15
+ serialize_assistant_message(message)
16
+ when ClaudeAgentSDK::ResultMessage
17
+ serialize_result_message(message)
18
+ when ClaudeAgentSDK::TaskStartedMessage
19
+ serialize_task_started_message(message)
20
+ when ClaudeAgentSDK::TaskProgressMessage
21
+ serialize_task_progress_message(message)
22
+ when ClaudeAgentSDK::TaskNotificationMessage
23
+ serialize_task_notification_message(message)
24
+ when ClaudeAgentSDK::SystemMessage
25
+ serialize_system_message(message)
26
+ when ClaudeAgentSDK::StreamEvent
27
+ serialize_stream_event(message)
28
+ when ClaudeAgentSDK::RateLimitEvent
29
+ serialize_rate_limit_event(message)
30
+ else
31
+ { type: 'unknown', data: message.to_s }
32
+ end
33
+ end
34
+
35
+ def serialize_user_message(msg)
36
+ {
37
+ type: 'user',
38
+ content: serialize_content(msg.content),
39
+ uuid: msg.uuid,
40
+ parentToolUseId: msg.parent_tool_use_id
41
+ }.compact
42
+ end
43
+
44
+ def serialize_assistant_message(msg)
45
+ {
46
+ type: 'assistant',
47
+ content: serialize_content(msg.content),
48
+ model: msg.model,
49
+ parentToolUseId: msg.parent_tool_use_id,
50
+ error: msg.error
51
+ }.compact
52
+ end
53
+
54
+ def serialize_result_message(msg)
55
+ {
56
+ type: 'result',
57
+ subtype: msg.subtype,
58
+ durationMs: msg.duration_ms,
59
+ durationApiMs: msg.duration_api_ms,
60
+ isError: msg.is_error,
61
+ numTurns: msg.num_turns,
62
+ sessionId: msg.session_id,
63
+ stopReason: msg.stop_reason,
64
+ totalCostUsd: msg.total_cost_usd,
65
+ usage: msg.usage,
66
+ result: msg.result,
67
+ structuredOutput: msg.structured_output
68
+ }.compact
69
+ end
70
+
71
+ def serialize_task_started_message(msg)
72
+ {
73
+ type: 'system',
74
+ subtype: 'task_started',
75
+ taskId: msg.task_id,
76
+ description: msg.description,
77
+ uuid: msg.uuid,
78
+ sessionId: msg.session_id,
79
+ toolUseId: msg.tool_use_id,
80
+ taskType: msg.task_type
81
+ }.compact
82
+ end
83
+
84
+ def serialize_task_progress_message(msg)
85
+ {
86
+ type: 'system',
87
+ subtype: 'task_progress',
88
+ taskId: msg.task_id,
89
+ description: msg.description,
90
+ usage: msg.usage,
91
+ uuid: msg.uuid,
92
+ sessionId: msg.session_id,
93
+ toolUseId: msg.tool_use_id,
94
+ lastToolName: msg.last_tool_name
95
+ }.compact
96
+ end
97
+
98
+ def serialize_task_notification_message(msg)
99
+ {
100
+ type: 'system',
101
+ subtype: 'task_notification',
102
+ taskId: msg.task_id,
103
+ status: msg.status,
104
+ outputFile: msg.output_file,
105
+ summary: msg.summary,
106
+ uuid: msg.uuid,
107
+ sessionId: msg.session_id,
108
+ toolUseId: msg.tool_use_id,
109
+ usage: msg.usage
110
+ }.compact
111
+ end
112
+
113
+ def serialize_system_message(msg)
114
+ {
115
+ type: 'system',
116
+ subtype: msg.subtype,
117
+ data: msg.data
118
+ }.compact
119
+ end
120
+
121
+ def serialize_stream_event(msg)
122
+ {
123
+ type: 'stream_event',
124
+ uuid: msg.uuid,
125
+ sessionId: msg.session_id,
126
+ event: msg.event,
127
+ parentToolUseId: msg.parent_tool_use_id
128
+ }.compact
129
+ end
130
+
131
+ def serialize_rate_limit_event(msg)
132
+ {
133
+ type: 'rate_limit_event',
134
+ data: msg.data
135
+ }.compact
136
+ end
137
+
138
+ def serialize_content(content)
139
+ return content.map { |block| serialize_content_block(block) } if content.is_a?(Array)
140
+
141
+ content
142
+ end
143
+
144
+ def serialize_content_block(block)
145
+ case block
146
+ when ClaudeAgentSDK::TextBlock
147
+ { type: 'text', text: block.text }
148
+ when ClaudeAgentSDK::ThinkingBlock
149
+ { type: 'thinking', thinking: block.thinking, signature: block.signature }
150
+ when ClaudeAgentSDK::ToolUseBlock
151
+ { type: 'tool_use', id: block.id, name: block.name, input: block.input }
152
+ when ClaudeAgentSDK::ToolResultBlock
153
+ result = { type: 'tool_result', toolUseId: block.tool_use_id }
154
+ result[:content] = block.content if block.content
155
+ result[:isError] = block.is_error unless block.is_error.nil?
156
+ result
157
+ when ClaudeAgentSDK::UnknownBlock
158
+ block.data
159
+ else
160
+ block
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'claude_agent_sdk'
4
+
5
+ module ClaudeAgentServer
6
+ module Services
7
+ module OptionsBuilder
8
+ # Map of camelCase JSON keys to snake_case ClaudeAgentOptions kwargs
9
+ CAMEL_TO_SNAKE = {
10
+ 'allowedTools' => :allowed_tools,
11
+ 'disallowedTools' => :disallowed_tools,
12
+ 'systemPrompt' => :system_prompt,
13
+ 'mcpServers' => :mcp_servers,
14
+ 'permissionMode' => :permission_mode,
15
+ 'continueConversation' => :continue_conversation,
16
+ 'resume' => :resume,
17
+ 'maxTurns' => :max_turns,
18
+ 'model' => :model,
19
+ 'cwd' => :cwd,
20
+ 'cliPath' => :cli_path,
21
+ 'env' => :env,
22
+ 'maxBudgetUsd' => :max_budget_usd,
23
+ 'maxThinkingTokens' => :max_thinking_tokens,
24
+ 'fallbackModel' => :fallback_model,
25
+ 'outputFormat' => :output_format,
26
+ 'includePartialMessages' => :include_partial_messages,
27
+ 'forkSession' => :fork_session,
28
+ 'enableFileCheckpointing' => :enable_file_checkpointing,
29
+ 'effort' => :effort,
30
+ 'appendAllowedTools' => :append_allowed_tools
31
+ }.freeze
32
+
33
+ module_function
34
+
35
+ def build(params)
36
+ kwargs = {}
37
+
38
+ params.each do |key, value|
39
+ snake_key = CAMEL_TO_SNAKE[key.to_s] || key.to_s.gsub(/([A-Z])/, '_\1').downcase.to_sym
40
+ kwargs[snake_key] = value
41
+ end
42
+
43
+ # Merge with server-level defaults
44
+ defaults = ClaudeAgentServer.config.default_sdk_options
45
+ merged = defaults.merge(kwargs)
46
+
47
+ # Force bypassPermissions for HTTP usage (no interactive terminal)
48
+ merged[:permission_mode] ||= 'bypassPermissions'
49
+
50
+ ClaudeAgentSDK::ClaudeAgentOptions.new(**merged)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'claude_agent_sdk'
4
+
5
+ module ClaudeAgentServer
6
+ module Services
7
+ module QueryExecutor
8
+ module_function
9
+
10
+ def execute(prompt:, options:)
11
+ messages = []
12
+
13
+ ClaudeAgentSDK.query(prompt: prompt, options: options) do |message|
14
+ messages << MessageSerializer.serialize(message)
15
+ end
16
+
17
+ messages
18
+ end
19
+
20
+ def stream(prompt:, options:, &block)
21
+ ClaudeAgentSDK.query(prompt: prompt, options: options) do |message|
22
+ block.call(message)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'claude_agent_sdk'
5
+ require 'async'
6
+ require 'async/queue'
7
+
8
+ module ClaudeAgentServer
9
+ module Services
10
+ # An indexed event wrapping an SDK message with a monotonic index
11
+ class SessionEvent
12
+ attr_reader :index, :message, :timestamp
13
+
14
+ def initialize(index:, message:)
15
+ @index = index
16
+ @message = message
17
+ @timestamp = Time.now
18
+ end
19
+ end
20
+
21
+ class SessionEntry
22
+ attr_reader :id, :client, :created_at, :status, :events
23
+ attr_accessor :last_activity
24
+
25
+ def initialize(id:, client:)
26
+ @id = id
27
+ @client = client
28
+ @created_at = Time.now
29
+ @last_activity = Time.now
30
+ @status = :connected
31
+ @events = []
32
+ @next_index = 0
33
+ @subscribers = []
34
+ @mutex = Mutex.new
35
+ @reader_task = nil
36
+ end
37
+
38
+ def message_count
39
+ @events.size
40
+ end
41
+
42
+ # Retrieve events by offset and limit (for polling)
43
+ def get_events(offset: 0, limit: nil)
44
+ @mutex.synchronize do
45
+ slice = @events[offset..] || []
46
+ limit ? slice.first(limit) : slice
47
+ end
48
+ end
49
+
50
+ def subscribe(offset: 0, &block)
51
+ queue = Async::Queue.new
52
+
53
+ # Replay missed events from offset
54
+ @mutex.synchronize do
55
+ @subscribers << queue
56
+ @events[offset..]&.each { |evt| queue.enqueue(evt) }
57
+ end
58
+
59
+ begin
60
+ loop do
61
+ event = queue.dequeue
62
+ break if event == :done
63
+
64
+ block.call(event)
65
+ end
66
+ ensure
67
+ @mutex.synchronize { @subscribers.delete(queue) }
68
+ end
69
+ end
70
+
71
+ def broadcast(message)
72
+ @mutex.synchronize do
73
+ event = SessionEvent.new(index: @next_index, message: message)
74
+ @next_index += 1
75
+ @events << event
76
+ @last_activity = Time.now
77
+ @subscribers.each { |q| q.enqueue(event) }
78
+ end
79
+ end
80
+
81
+ def finish
82
+ @mutex.synchronize do
83
+ @status = :finished
84
+ @subscribers.each { |q| q.enqueue(:done) }
85
+ end
86
+ end
87
+
88
+ def start_message_reader
89
+ @reader_task = Thread.new do
90
+ Async do
91
+ client.receive_messages do |message|
92
+ broadcast(message)
93
+ break if message.is_a?(ClaudeAgentSDK::ResultMessage)
94
+ end
95
+ rescue StandardError
96
+ # Reader ended (disconnect or error)
97
+ ensure
98
+ finish
99
+ end
100
+ end
101
+ end
102
+
103
+ def disconnect
104
+ @status = :disconnected
105
+ @reader_task&.kill
106
+ @client.disconnect
107
+ @mutex.synchronize do
108
+ @subscribers.each { |q| q.enqueue(:done) }
109
+ @subscribers.clear
110
+ end
111
+ end
112
+ end
113
+
114
+ class SessionManager
115
+ attr_reader :sessions
116
+
117
+ def initialize
118
+ @sessions = {}
119
+ @mutex = Mutex.new
120
+ @reaper_task = nil
121
+ end
122
+
123
+ def create_session(options:, prompt: nil, id: nil)
124
+ config = ClaudeAgentServer.config
125
+ id ||= SecureRandom.uuid
126
+
127
+ @mutex.synchronize do
128
+ raise SessionAlreadyExistsError, "Session '#{id}' already exists" if @sessions.key?(id)
129
+
130
+ if @sessions.size >= config.max_sessions
131
+ raise SessionLimitError, "Maximum session limit (#{config.max_sessions}) reached"
132
+ end
133
+ end
134
+
135
+ client = ClaudeAgentSDK::Client.new(options: options)
136
+ client.connect(prompt)
137
+
138
+ entry = SessionEntry.new(id: id, client: client)
139
+ @mutex.synchronize { @sessions[id] = entry }
140
+
141
+ entry.start_message_reader
142
+ entry
143
+ end
144
+
145
+ def get_session(id)
146
+ @mutex.synchronize { @sessions[id] } || raise(SessionNotFoundError, "Session '#{id}' not found")
147
+ end
148
+
149
+ def destroy_session(id)
150
+ entry = @mutex.synchronize { @sessions.delete(id) }
151
+ raise SessionNotFoundError, "Session '#{id}' not found" unless entry
152
+
153
+ entry.disconnect
154
+ entry
155
+ end
156
+
157
+ def list_sessions
158
+ @mutex.synchronize { @sessions.values.dup }
159
+ end
160
+
161
+ def start_reaper
162
+ @reaper_task = Async do |task|
163
+ loop do
164
+ task.sleep(60)
165
+ reap_expired_sessions
166
+ end
167
+ end
168
+ end
169
+
170
+ def stop_reaper
171
+ @reaper_task&.stop
172
+ end
173
+
174
+ def shutdown
175
+ stop_reaper
176
+ @mutex.synchronize do
177
+ @sessions.each_value(&:disconnect)
178
+ @sessions.clear
179
+ end
180
+ end
181
+
182
+ private
183
+
184
+ def reap_expired_sessions
185
+ ttl = ClaudeAgentServer.config.session_ttl
186
+ now = Time.now
187
+
188
+ expired_ids = @mutex.synchronize do
189
+ @sessions.select { |_, entry| now - entry.last_activity > ttl }.keys
190
+ end
191
+
192
+ expired_ids.each do |id|
193
+ entry = @mutex.synchronize { @sessions.delete(id) }
194
+ entry&.disconnect
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module ClaudeAgentServer
6
+ module Services
7
+ module SseStream
8
+ module_function
9
+
10
+ def stream_query(prompt:, options:)
11
+ index = 0
12
+ StreamBody.new do |stream|
13
+ QueryExecutor.stream(prompt: prompt, options: options) do |message|
14
+ serialized = MessageSerializer.serialize(message)
15
+ event_type = serialized[:type] || 'message'
16
+ stream.write(format_sse(event_type, serialized, id: index))
17
+ index += 1
18
+ end
19
+ stream.write(format_sse('done', { status: 'complete' }))
20
+ rescue IOError, Errno::EPIPE
21
+ # Client disconnected — exit cleanly
22
+ end
23
+ end
24
+
25
+ def stream_session(session_entry, last_event_id: nil)
26
+ offset = last_event_id ? last_event_id.to_i + 1 : 0
27
+
28
+ StreamBody.new do |stream|
29
+ session_entry.subscribe(offset: offset) do |event|
30
+ serialized = MessageSerializer.serialize(event.message)
31
+ event_type = serialized[:type] || 'message'
32
+ stream.write(format_sse(event_type, serialized, id: event.index))
33
+ end
34
+ stream.write(format_sse('done', { status: 'complete' }))
35
+ rescue IOError, Errno::EPIPE
36
+ # Client disconnected — session stays alive
37
+ end
38
+ end
39
+
40
+ def format_sse(event, data, id: nil)
41
+ parts = []
42
+ parts << "id: #{id}" unless id.nil?
43
+ parts << "event: #{event}"
44
+ parts << "data: #{JSON.generate(data)}"
45
+ "#{parts.join("\n")}\n\n"
46
+ end
47
+
48
+ # Rack 3 streaming body that yields chunks via a fiber
49
+ class StreamBody
50
+ def initialize(&block)
51
+ @block = block
52
+ end
53
+
54
+ def each(&chunk_block)
55
+ stream = StreamWriter.new(chunk_block)
56
+ @block.call(stream)
57
+ end
58
+ end
59
+
60
+ class StreamWriter
61
+ def initialize(chunk_block)
62
+ @chunk_block = chunk_block
63
+ end
64
+
65
+ def write(data)
66
+ @chunk_block.call(data)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgentServer
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'claude_agent_server/version'
4
+ require_relative 'claude_agent_server/errors'
5
+ require_relative 'claude_agent_server/config'
6
+ require_relative 'claude_agent_server/services/options_builder'
7
+ require_relative 'claude_agent_server/services/message_serializer'
8
+ require_relative 'claude_agent_server/services/query_executor'
9
+ require_relative 'claude_agent_server/services/sse_stream'
10
+ require_relative 'claude_agent_server/services/session_manager'
11
+ require_relative 'claude_agent_server/middleware/request_id'
12
+ require_relative 'claude_agent_server/middleware/error_handler'
13
+ require_relative 'claude_agent_server/middleware/authentication'
14
+ require_relative 'claude_agent_server/middleware/cors'
15
+ require_relative 'claude_agent_server/app'
16
+
17
+ module ClaudeAgentServer
18
+ @config = Config.new
19
+
20
+ class << self
21
+ attr_reader :config
22
+
23
+ def configure
24
+ yield @config if block_given?
25
+ @config
26
+ end
27
+
28
+ def reset_config!
29
+ @config = Config.new
30
+ end
31
+
32
+ def app
33
+ App.freeze.app
34
+ end
35
+ end
36
+ end
metadata ADDED
@@ -0,0 +1,107 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: claude-agent-server
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Community Contributors
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2026-03-06 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: claude-agent-sdk
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.8'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.8'
26
+ - !ruby/object:Gem::Dependency
27
+ name: falcon
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.48'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.48'
40
+ - !ruby/object:Gem::Dependency
41
+ name: roda
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.85'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.85'
54
+ description: REST + SSE HTTP wrapper for claude-agent-sdk. Exposes Claude Code as
55
+ a network service with session management, streaming, and authentication.
56
+ email: []
57
+ executables:
58
+ - claude-agent-server
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - CHANGELOG.md
63
+ - LICENSE
64
+ - README.md
65
+ - config.ru
66
+ - docs/openapi.json
67
+ - exe/claude-agent-server
68
+ - lib/claude_agent_server.rb
69
+ - lib/claude_agent_server/app.rb
70
+ - lib/claude_agent_server/config.rb
71
+ - lib/claude_agent_server/errors.rb
72
+ - lib/claude_agent_server/middleware/authentication.rb
73
+ - lib/claude_agent_server/middleware/cors.rb
74
+ - lib/claude_agent_server/middleware/error_handler.rb
75
+ - lib/claude_agent_server/middleware/request_id.rb
76
+ - lib/claude_agent_server/services/message_serializer.rb
77
+ - lib/claude_agent_server/services/options_builder.rb
78
+ - lib/claude_agent_server/services/query_executor.rb
79
+ - lib/claude_agent_server/services/session_manager.rb
80
+ - lib/claude_agent_server/services/sse_stream.rb
81
+ - lib/claude_agent_server/version.rb
82
+ homepage: https://github.com/ya-luotao/claude-agent-server-ruby
83
+ licenses:
84
+ - MIT
85
+ metadata:
86
+ homepage_uri: https://github.com/ya-luotao/claude-agent-server-ruby
87
+ source_code_uri: https://github.com/ya-luotao/claude-agent-server-ruby
88
+ changelog_uri: https://github.com/ya-luotao/claude-agent-server-ruby/blob/main/CHANGELOG.md
89
+ rubygems_mfa_required: 'true'
90
+ rdoc_options: []
91
+ require_paths:
92
+ - lib
93
+ required_ruby_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: 3.2.0
98
+ required_rubygems_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ requirements: []
104
+ rubygems_version: 3.6.2
105
+ specification_version: 4
106
+ summary: HTTP server wrapping the Claude Agent Ruby SDK
107
+ test_files: []