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,71 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'optparse'
5
+
6
+ options = {}
7
+
8
+ OptionParser.new do |opts|
9
+ opts.banner = 'Usage: claude-agent-server [options]'
10
+
11
+ opts.on('-p', '--port PORT', Integer, 'Port to listen on (default: 9292)') do |p|
12
+ options[:port] = p
13
+ end
14
+
15
+ opts.on('-b', '--bind HOST', 'Host to bind to (default: 0.0.0.0)') do |h|
16
+ options[:host] = h
17
+ end
18
+
19
+ opts.on('-t', '--token TOKEN', 'Authentication token') do |t|
20
+ options[:auth_token] = t
21
+ end
22
+
23
+ opts.on('--session-ttl SECONDS', Integer, 'Session TTL in seconds (default: 3600)') do |ttl|
24
+ options[:session_ttl] = ttl
25
+ end
26
+
27
+ opts.on('--max-sessions N', Integer, 'Maximum concurrent sessions (default: 100)') do |n|
28
+ options[:max_sessions] = n
29
+ end
30
+
31
+ opts.on('--cors-origins ORIGINS', 'Comma-separated CORS origins (default: *)') do |origins|
32
+ options[:cors_origins] = origins
33
+ end
34
+
35
+ opts.on('-v', '--version', 'Show version') do
36
+ require_relative '../lib/claude_agent_server/version'
37
+ puts "claude-agent-server #{ClaudeAgentServer::VERSION}"
38
+ exit
39
+ end
40
+
41
+ opts.on('-h', '--help', 'Show help') do
42
+ puts opts
43
+ exit
44
+ end
45
+ end.parse!
46
+
47
+ require_relative '../lib/claude_agent_server'
48
+
49
+ ClaudeAgentServer.configure do |config|
50
+ config.port = options[:port] if options[:port]
51
+ config.host = options[:host] if options[:host]
52
+ config.auth_token = options[:auth_token] if options[:auth_token]
53
+ config.session_ttl = options[:session_ttl] if options[:session_ttl]
54
+ config.max_sessions = options[:max_sessions] if options[:max_sessions]
55
+ config.cors_origins = options[:cors_origins] if options[:cors_origins]
56
+ end
57
+
58
+ config = ClaudeAgentServer.config
59
+
60
+ puts "Starting claude-agent-server v#{ClaudeAgentServer::VERSION}"
61
+ puts "Listening on http://#{config.host}:#{config.port}"
62
+ puts "Auth: #{config.auth_enabled? ? 'enabled' : 'disabled'}"
63
+ puts "Max sessions: #{config.max_sessions}, TTL: #{config.session_ttl}s"
64
+
65
+ # Use array form to avoid shell injection
66
+ Kernel.exec(
67
+ 'falcon', 'serve',
68
+ '--bind', "http://#{config.host}:#{config.port}",
69
+ '--count', '1',
70
+ '--config', File.expand_path('../../config.ru', __dir__)
71
+ )
@@ -0,0 +1,337 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'roda'
4
+ require 'json'
5
+ require 'claude_agent_sdk'
6
+
7
+ module ClaudeAgentServer
8
+ class App < Roda
9
+ plugin :request_headers
10
+ plugin :all_verbs
11
+
12
+ @session_manager = Services::SessionManager.new
13
+
14
+ class << self
15
+ attr_reader :session_manager
16
+ end
17
+
18
+ use Middleware::RequestId
19
+ use Middleware::Cors
20
+ use Middleware::Authentication
21
+ use Middleware::ErrorHandler
22
+
23
+ route do |r| # rubocop:disable Metrics/BlockLength
24
+ # GET /health (outside /v1 — always accessible)
25
+ r.on 'health' do
26
+ r.get do
27
+ json_response({ status: 'ok' })
28
+ end
29
+ end
30
+
31
+ # All API routes under /v1
32
+ r.on 'v1' do # rubocop:disable Metrics/BlockLength
33
+ # GET /v1/health
34
+ r.on 'health' do
35
+ r.get do
36
+ json_response({ status: 'ok' })
37
+ end
38
+ end
39
+
40
+ # GET /v1/openapi.json
41
+ r.on 'openapi.json' do
42
+ r.get do
43
+ spec_path = File.expand_path('../../docs/openapi.json', __dir__)
44
+ response['content-type'] = 'application/json'
45
+ File.read(spec_path)
46
+ end
47
+ end
48
+
49
+ # GET /v1/info
50
+ r.on 'info' do
51
+ r.get do
52
+ manager = self.class.session_manager
53
+ json_response({
54
+ version: ClaudeAgentServer::VERSION,
55
+ sdkVersion: ClaudeAgentSDK::VERSION,
56
+ activeSessions: manager.sessions.size
57
+ })
58
+ end
59
+ end
60
+
61
+ # /v1/query routes
62
+ r.on 'query' do
63
+ # POST /v1/query/stream
64
+ r.on 'stream' do
65
+ r.post do
66
+ params = parse_json_body(request)
67
+ prompt = params['prompt'] || params[:prompt]
68
+ raise ArgumentError, 'Missing required field: prompt' unless prompt
69
+
70
+ sdk_options = params['options'] || params[:options] || {}
71
+ options = Services::OptionsBuilder.build(sdk_options)
72
+
73
+ response['content-type'] = 'text/event-stream'
74
+ response['cache-control'] = 'no-cache'
75
+ response['x-accel-buffering'] = 'no'
76
+
77
+ body = Services::SseStream.stream_query(prompt: prompt, options: options)
78
+ r.halt [200, response.headers, body]
79
+ end
80
+ end
81
+
82
+ # POST /v1/query
83
+ r.is do
84
+ r.post do
85
+ params = parse_json_body(request)
86
+ prompt = params['prompt'] || params[:prompt]
87
+ raise ArgumentError, 'Missing required field: prompt' unless prompt
88
+
89
+ sdk_options = params['options'] || params[:options] || {}
90
+ options = Services::OptionsBuilder.build(sdk_options)
91
+
92
+ messages = Services::QueryExecutor.execute(prompt: prompt, options: options)
93
+ json_response({ messages: messages })
94
+ end
95
+ end
96
+ end
97
+
98
+ # /v1/cli-sessions routes (read-only CLI session browsing)
99
+ r.on 'cli-sessions' do # rubocop:disable Metrics/BlockLength
100
+ r.is do
101
+ r.get do
102
+ params = r.params
103
+ directory = params['directory']
104
+ limit = params['limit']&.to_i
105
+ include_worktrees = params['includeWorktrees'] != 'false'
106
+
107
+ sessions = ClaudeAgentSDK.list_sessions(
108
+ directory: directory, limit: limit, include_worktrees: include_worktrees
109
+ )
110
+ json_response({ sessions: sessions.map { |s| serialize_sdk_session(s) } })
111
+ end
112
+ end
113
+
114
+ r.on String do |session_id|
115
+ r.on 'messages' do
116
+ r.get do
117
+ params = r.params
118
+ messages = ClaudeAgentSDK.get_session_messages(
119
+ session_id: session_id,
120
+ directory: params['directory'],
121
+ limit: params['limit']&.to_i,
122
+ offset: (params['offset'] || '0').to_i
123
+ )
124
+ json_response({
125
+ sessionId: session_id,
126
+ messages: messages.map { |m| serialize_session_message(m) }
127
+ })
128
+ end
129
+ end
130
+ end
131
+ end
132
+
133
+ # /v1/sessions routes
134
+ r.on 'sessions' do # rubocop:disable Metrics/BlockLength
135
+ manager = self.class.session_manager
136
+
137
+ r.is do
138
+ r.get do
139
+ entries = manager.list_sessions
140
+ json_response({ sessions: entries.map { |e| serialize_session_info(e) } })
141
+ end
142
+
143
+ r.post do
144
+ params = parse_json_body(request)
145
+ prompt = params['prompt'] || params[:prompt]
146
+ session_id = params['id'] || params[:id]
147
+ sdk_options = params['options'] || params[:options] || {}
148
+ options = Services::OptionsBuilder.build(sdk_options)
149
+
150
+ entry = manager.create_session(options: options, prompt: prompt, id: session_id)
151
+ response.status = 201
152
+ json_response(serialize_session_info(entry))
153
+ end
154
+ end
155
+
156
+ r.on String do |session_id| # rubocop:disable Metrics/BlockLength
157
+ r.is do
158
+ r.get do
159
+ entry = manager.get_session(session_id)
160
+ json_response(serialize_session_info(entry))
161
+ end
162
+
163
+ r.delete do
164
+ manager.destroy_session(session_id)
165
+ json_response({ status: 'disconnected', id: session_id })
166
+ end
167
+ end
168
+
169
+ # /v1/sessions/:id/events — offset-based polling
170
+ r.on 'events' do
171
+ # GET /v1/sessions/:id/events/sse — SSE stream with resume
172
+ r.on 'sse' do
173
+ r.get do
174
+ entry = manager.get_session(session_id)
175
+ last_event_id = env['HTTP_LAST_EVENT_ID']
176
+
177
+ response['content-type'] = 'text/event-stream'
178
+ response['cache-control'] = 'no-cache'
179
+ response['x-accel-buffering'] = 'no'
180
+
181
+ body = Services::SseStream.stream_session(entry, last_event_id: last_event_id)
182
+ r.halt [200, response.headers, body]
183
+ end
184
+ end
185
+
186
+ # GET /v1/sessions/:id/events?offset=N&limit=M
187
+ r.is do
188
+ r.get do
189
+ entry = manager.get_session(session_id)
190
+ params = r.params
191
+ offset = (params['offset'] || '0').to_i
192
+ limit = params['limit']&.to_i
193
+
194
+ events = entry.get_events(offset: offset, limit: limit)
195
+ json_response({
196
+ sessionId: session_id,
197
+ events: events.map { |e| serialize_event(e) },
198
+ nextOffset: events.empty? ? offset : events.last.index + 1
199
+ })
200
+ end
201
+ end
202
+ end
203
+
204
+ # POST /v1/sessions/:id/messages
205
+ r.on 'messages' do
206
+ # GET /v1/sessions/:id/messages/stream (legacy SSE)
207
+ r.on 'stream' do
208
+ r.get do
209
+ entry = manager.get_session(session_id)
210
+ last_event_id = env['HTTP_LAST_EVENT_ID']
211
+
212
+ response['content-type'] = 'text/event-stream'
213
+ response['cache-control'] = 'no-cache'
214
+ response['x-accel-buffering'] = 'no'
215
+
216
+ body = Services::SseStream.stream_session(entry, last_event_id: last_event_id)
217
+ r.halt [200, response.headers, body]
218
+ end
219
+ end
220
+
221
+ r.is do
222
+ r.post do
223
+ entry = manager.get_session(session_id)
224
+ params = parse_json_body(request)
225
+ prompt = params['prompt'] || params[:prompt]
226
+ raise ArgumentError, 'Missing required field: prompt' unless prompt
227
+
228
+ entry.last_activity = Time.now
229
+ entry.client.query(prompt)
230
+ json_response({ status: 'sent', sessionId: session_id })
231
+ end
232
+ end
233
+ end
234
+
235
+ r.on 'interrupt' do
236
+ r.post do
237
+ entry = manager.get_session(session_id)
238
+ entry.client.interrupt
239
+ entry.last_activity = Time.now
240
+ json_response({ status: 'interrupted', sessionId: session_id })
241
+ end
242
+ end
243
+
244
+ r.on 'model' do
245
+ r.post do
246
+ entry = manager.get_session(session_id)
247
+ params = parse_json_body(request)
248
+ model = params['model'] || params[:model]
249
+ raise ArgumentError, 'Missing required field: model' unless model
250
+
251
+ entry.client.set_model(model)
252
+ entry.last_activity = Time.now
253
+ json_response({ status: 'model_changed', model: model, sessionId: session_id })
254
+ end
255
+ end
256
+
257
+ r.on 'mcp-status' do
258
+ r.get do
259
+ entry = manager.get_session(session_id)
260
+ mcp_status = entry.client.get_mcp_status
261
+ json_response({ sessionId: session_id, mcpStatus: mcp_status })
262
+ end
263
+ end
264
+
265
+ r.on 'history' do
266
+ r.get do
267
+ entry = manager.get_session(session_id)
268
+ events = entry.events
269
+ json_response({
270
+ sessionId: session_id,
271
+ messages: events.map { |e| serialize_event(e) }
272
+ })
273
+ end
274
+ end
275
+ end
276
+ end
277
+ end
278
+ end
279
+
280
+ private
281
+
282
+ def json_response(data)
283
+ response['content-type'] = 'application/json'
284
+ JSON.generate(data)
285
+ end
286
+
287
+ def parse_json_body(request)
288
+ body = request.body.read
289
+ return {} if body.nil? || body.empty?
290
+
291
+ JSON.parse(body)
292
+ rescue JSON::ParserError => e
293
+ raise ArgumentError, "Invalid JSON body: #{e.message}"
294
+ end
295
+
296
+ def serialize_session_info(entry)
297
+ {
298
+ id: entry.id,
299
+ status: entry.status.to_s,
300
+ createdAt: entry.created_at.iso8601,
301
+ lastActivity: entry.last_activity.iso8601,
302
+ messageCount: entry.message_count
303
+ }
304
+ end
305
+
306
+ def serialize_event(event)
307
+ {
308
+ index: event.index,
309
+ timestamp: event.timestamp.iso8601,
310
+ message: Services::MessageSerializer.serialize(event.message)
311
+ }
312
+ end
313
+
314
+ def serialize_sdk_session(session)
315
+ {
316
+ sessionId: session.session_id,
317
+ summary: session.summary,
318
+ lastModified: session.last_modified,
319
+ fileSize: session.file_size,
320
+ customTitle: session.custom_title,
321
+ firstPrompt: session.first_prompt,
322
+ gitBranch: session.git_branch,
323
+ cwd: session.cwd
324
+ }.compact
325
+ end
326
+
327
+ def serialize_session_message(msg)
328
+ {
329
+ type: msg.type,
330
+ uuid: msg.uuid,
331
+ sessionId: msg.session_id,
332
+ message: msg.message,
333
+ parentToolUseId: msg.parent_tool_use_id
334
+ }.compact
335
+ end
336
+ end
337
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgentServer
4
+ class Config
5
+ attr_accessor :host, :port, :auth_token, :cors_origins, :session_ttl,
6
+ :max_sessions, :default_sdk_options, :log_level
7
+
8
+ def initialize
9
+ @host = ENV.fetch('CLAUDE_SERVER_HOST', '0.0.0.0')
10
+ @port = ENV.fetch('CLAUDE_SERVER_PORT', '9292').to_i
11
+ @auth_token = ENV.fetch('CLAUDE_SERVER_AUTH_TOKEN', nil)
12
+ @cors_origins = ENV.fetch('CLAUDE_SERVER_CORS_ORIGINS', '*')
13
+ @session_ttl = ENV.fetch('CLAUDE_SERVER_SESSION_TTL', '3600').to_i
14
+ @max_sessions = ENV.fetch('CLAUDE_SERVER_MAX_SESSIONS', '100').to_i
15
+ @default_sdk_options = {}
16
+ @log_level = ENV.fetch('CLAUDE_SERVER_LOG_LEVEL', 'info')
17
+ end
18
+
19
+ def auth_enabled?
20
+ !@auth_token.nil? && !@auth_token.empty?
21
+ end
22
+
23
+ def allowed_origins
24
+ return ['*'] if @cors_origins == '*'
25
+
26
+ @cors_origins.split(',').map(&:strip)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgentServer
4
+ URN_PREFIX = 'urn:claude-agent-server:error'
5
+
6
+ class ServerError < StandardError
7
+ def error_type = 'server_error'
8
+ def status_code = 500
9
+ def title = 'Server Error'
10
+ def urn = "#{URN_PREFIX}:#{error_type}"
11
+
12
+ def to_problem_details
13
+ {
14
+ type: urn,
15
+ title: title,
16
+ status: status_code,
17
+ detail: message
18
+ }
19
+ end
20
+ end
21
+
22
+ class ConfigError < ServerError
23
+ def error_type = 'config_error'
24
+ def status_code = 400
25
+ def title = 'Configuration Error'
26
+ end
27
+
28
+ class SessionNotFoundError < ServerError
29
+ def error_type = 'session_not_found'
30
+ def status_code = 404
31
+ def title = 'Session Not Found'
32
+ end
33
+
34
+ class SessionAlreadyExistsError < ServerError
35
+ def error_type = 'session_already_exists'
36
+ def status_code = 409
37
+ def title = 'Session Already Exists'
38
+ end
39
+
40
+ class SessionLimitError < ServerError
41
+ def error_type = 'session_limit_reached'
42
+ def status_code = 429
43
+ def title = 'Session Limit Reached'
44
+ end
45
+
46
+ class AuthenticationError < ServerError
47
+ def error_type = 'unauthorized'
48
+ def status_code = 401
49
+ def title = 'Unauthorized'
50
+ end
51
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'openssl'
5
+
6
+ module ClaudeAgentServer
7
+ module Middleware
8
+ class Authentication
9
+ SKIP_PATHS = %w[/health /v1/health].freeze
10
+
11
+ def initialize(app)
12
+ @app = app
13
+ end
14
+
15
+ def call(env)
16
+ return @app.call(env) unless ClaudeAgentServer.config.auth_enabled?
17
+ return @app.call(env) if skip_auth?(env)
18
+
19
+ token = extract_token(env)
20
+ return unauthorized_response unless token && timing_safe_compare(token, ClaudeAgentServer.config.auth_token)
21
+
22
+ @app.call(env)
23
+ end
24
+
25
+ private
26
+
27
+ def skip_auth?(env)
28
+ SKIP_PATHS.include?(env['PATH_INFO'])
29
+ end
30
+
31
+ def extract_token(env)
32
+ auth_header = env['HTTP_AUTHORIZATION']
33
+ return nil unless auth_header
34
+
35
+ match = auth_header.match(/\ABearer\s+(.+)\z/i)
36
+ match&.captures&.first
37
+ end
38
+
39
+ def timing_safe_compare(provided, expected)
40
+ OpenSSL.fixed_length_secure_compare(
41
+ OpenSSL::Digest::SHA256.digest(provided),
42
+ OpenSSL::Digest::SHA256.digest(expected)
43
+ )
44
+ end
45
+
46
+ def unauthorized_response
47
+ body = JSON.generate({
48
+ type: "#{URN_PREFIX}:unauthorized",
49
+ title: 'Unauthorized',
50
+ status: 401,
51
+ detail: 'Invalid or missing authentication token'
52
+ })
53
+ [401, { 'content-type' => 'application/problem+json' }, [body]]
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgentServer
4
+ module Middleware
5
+ class Cors
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(env)
11
+ origin = env['HTTP_ORIGIN']
12
+
13
+ return preflight_response(origin) if env['REQUEST_METHOD'] == 'OPTIONS'
14
+
15
+ status, headers, body = @app.call(env)
16
+ add_cors_headers(headers, origin)
17
+ [status, headers, body]
18
+ end
19
+
20
+ private
21
+
22
+ def preflight_response(origin)
23
+ headers = {
24
+ 'content-type' => 'text/plain',
25
+ 'access-control-max-age' => '86400'
26
+ }
27
+ add_cors_headers(headers, origin)
28
+ headers['access-control-allow-methods'] = 'GET, POST, DELETE, OPTIONS'
29
+ headers['access-control-allow-headers'] = 'Content-Type, Authorization, X-Request-Id'
30
+ [204, headers, []]
31
+ end
32
+
33
+ def add_cors_headers(headers, origin)
34
+ allowed = ClaudeAgentServer.config.allowed_origins
35
+
36
+ if allowed.include?('*')
37
+ headers['access-control-allow-origin'] = '*'
38
+ elsif origin && allowed.include?(origin)
39
+ headers['access-control-allow-origin'] = origin
40
+ headers['vary'] = 'Origin'
41
+ end
42
+
43
+ headers['access-control-expose-headers'] = 'X-Request-Id'
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module ClaudeAgentServer
6
+ module Middleware
7
+ class ErrorHandler
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(env)
13
+ @app.call(env)
14
+ rescue ServerError => e
15
+ problem_response(e.status_code, e.to_problem_details)
16
+ rescue ClaudeAgentSDK::CLINotFoundError => e
17
+ problem_response(503, problem(503, 'cli_not_found', 'CLI Not Found', e.message))
18
+ rescue ClaudeAgentSDK::CLIConnectionError => e
19
+ problem_response(502, problem(502, 'cli_connection_error', 'CLI Connection Error', e.message))
20
+ rescue ClaudeAgentSDK::ProcessError => e
21
+ problem_response(502, problem(502, 'cli_process_error', 'CLI Process Error', e.message))
22
+ rescue ClaudeAgentSDK::ClaudeSDKError => e
23
+ problem_response(502, problem(502, 'sdk_error', 'SDK Error', e.message))
24
+ rescue ArgumentError => e
25
+ problem_response(400, problem(400, 'invalid_request', 'Invalid Request', e.message))
26
+ rescue StandardError => e
27
+ problem_response(500, problem(500, 'internal_error', 'Internal Error', e.message))
28
+ end
29
+
30
+ private
31
+
32
+ def problem(status, error_type, title, detail)
33
+ {
34
+ type: "#{URN_PREFIX}:#{error_type}",
35
+ title: title,
36
+ status: status,
37
+ detail: detail
38
+ }
39
+ end
40
+
41
+ def problem_response(status, body)
42
+ [status, { 'content-type' => 'application/problem+json' }, [JSON.generate(body)]]
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module ClaudeAgentServer
6
+ module Middleware
7
+ class RequestId
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(env)
13
+ request_id = env['HTTP_X_REQUEST_ID'] || SecureRandom.uuid
14
+ env['claude_agent_server.request_id'] = request_id
15
+
16
+ status, headers, body = @app.call(env)
17
+ headers['x-request-id'] = request_id
18
+ [status, headers, body]
19
+ end
20
+ end
21
+ end
22
+ end