@absmartly/claude-code-bridge 1.0.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.
Files changed (3) hide show
  1. package/README.md +84 -0
  2. package/index.js +481 -0
  3. package/package.json +42 -0
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # @absmartly/claude-code-bridge
2
+
3
+ HTTP bridge server that enables the ABsmartly Browser Extension to communicate with Claude Code CLI for AI-powered A/B testing features.
4
+
5
+ ## Prerequisites
6
+
7
+ - Node.js 16+
8
+ - Claude Code CLI authenticated: `npx @anthropic-ai/claude-code login`
9
+
10
+ ## Quick Start
11
+
12
+ ```bash
13
+ npx @absmartly/claude-code-bridge
14
+ ```
15
+
16
+ The server will start on `http://localhost:3000` by default.
17
+
18
+ ## Usage
19
+
20
+ ### 1. Login to Claude CLI (one-time setup)
21
+
22
+ ```bash
23
+ npx @anthropic-ai/claude-code login
24
+ ```
25
+
26
+ Follow the prompts to authenticate with your Claude subscription.
27
+
28
+ ### 2. Start the bridge server
29
+
30
+ ```bash
31
+ npx @absmartly/claude-code-bridge
32
+ ```
33
+
34
+ ### 3. Configure ABsmartly Extension
35
+
36
+ In the extension settings:
37
+ 1. Select "Claude Subscription" as your AI provider
38
+ 2. The extension will automatically connect to `http://localhost:3000`
39
+
40
+ ### Custom Port
41
+
42
+ ```bash
43
+ PORT=3001 npx @absmartly/claude-code-bridge
44
+ ```
45
+
46
+ ## API Endpoints
47
+
48
+ - `GET /health` - Health check and auth status
49
+ - `GET /auth/status` - Claude CLI authentication status
50
+ - `POST /conversations` - Create new conversation
51
+ - `POST /conversations/:id/messages` - Send message to Claude
52
+ - `GET /conversations/:id/stream` - Stream Claude responses (SSE)
53
+ - `POST /conversations/:id/approve` - Approve tool use
54
+ - `POST /conversations/:id/deny` - Deny tool use
55
+
56
+ ## How It Works
57
+
58
+ 1. **Authentication Check**: Reads your Claude credentials from `~/.claude/.credentials.json`
59
+ 2. **Claude CLI Spawn**: Spawns `npx @anthropic-ai/claude-code --json` subprocess
60
+ 3. **HTTP Bridge**: Provides REST API for the browser extension to communicate
61
+ 4. **Message Forwarding**: Routes messages between the extension and Claude CLI
62
+ 5. **Server-Sent Events**: Streams Claude responses back to the extension in real-time
63
+
64
+ ## Troubleshooting
65
+
66
+ ### "Claude CLI not logged in"
67
+
68
+ Run: `npx @anthropic-ai/claude-code login`
69
+
70
+ ### "Port already in use"
71
+
72
+ Either:
73
+ - Kill the existing process: `pkill -f claude-code-bridge`
74
+ - Or use a different port: `PORT=3001 npx @absmartly/claude-code-bridge`
75
+
76
+ ### Extension can't connect
77
+
78
+ - Ensure the bridge is running: `http://localhost:3000/health` should return JSON
79
+ - Check the port matches in extension settings
80
+ - Restart the extension
81
+
82
+ ## License
83
+
84
+ MIT
package/index.js ADDED
@@ -0,0 +1,481 @@
1
+ #!/usr/bin/env node
2
+
3
+ const express = require('express')
4
+ const cors = require('cors')
5
+ const { spawn } = require('child_process')
6
+ const fs = require('fs')
7
+ const path = require('path')
8
+ const os = require('os')
9
+
10
+ const PREFERRED_PORTS = [3000, 3001, 3002, 3003, 3004]
11
+ const PORT = process.env.PORT ? parseInt(process.env.PORT) : null
12
+
13
+ const app = express()
14
+ app.use(cors())
15
+ app.use(express.json({ limit: '10mb' }))
16
+
17
+ const claudeProcesses = new Map()
18
+ const activeStreams = new Map()
19
+ const conversationMessages = new Map()
20
+ const sessionTracking = new Map()
21
+ const outputBuffers = new Map()
22
+
23
+ function checkClaudeAuth() {
24
+ try {
25
+ const credentialsPath = path.join(os.homedir(), '.claude', '.credentials.json')
26
+
27
+ if (!fs.existsSync(credentialsPath)) {
28
+ return {
29
+ authenticated: false,
30
+ error: 'Claude CLI not logged in. Run: npx @anthropic-ai/claude-code login'
31
+ }
32
+ }
33
+
34
+ const credentials = JSON.parse(fs.readFileSync(credentialsPath, 'utf8'))
35
+
36
+ if (!credentials.claudeAiOauth) {
37
+ return {
38
+ authenticated: false,
39
+ error: 'No Claude OAuth credentials found'
40
+ }
41
+ }
42
+
43
+ const { expiresAt, subscriptionType } = credentials.claudeAiOauth
44
+ const isExpired = new Date(expiresAt) < new Date()
45
+
46
+ if (isExpired) {
47
+ return {
48
+ authenticated: false,
49
+ error: `Claude credentials expired at: ${expiresAt}`
50
+ }
51
+ }
52
+
53
+ return {
54
+ authenticated: true,
55
+ subscriptionType,
56
+ expiresAt
57
+ }
58
+ } catch (error) {
59
+ return {
60
+ authenticated: false,
61
+ error: `Failed to check Claude credentials: ${error.message}`
62
+ }
63
+ }
64
+ }
65
+
66
+ // JSON schema for structured DOM changes output
67
+ const DOM_CHANGES_SCHEMA = {
68
+ type: 'object',
69
+ properties: {
70
+ domChanges: {
71
+ type: 'array',
72
+ description: 'Array of DOM change instruction objects.'
73
+ },
74
+ response: {
75
+ type: 'string',
76
+ description: 'Conversational explanation and reasoning.'
77
+ },
78
+ action: {
79
+ type: 'string',
80
+ enum: ['append', 'replace_all', 'replace_specific', 'remove_specific', 'none'],
81
+ description: 'How the DOM changes should be applied.'
82
+ },
83
+ targetSelectors: {
84
+ type: 'array',
85
+ description: 'Selectors to target for replace/remove actions.',
86
+ items: { type: 'string' }
87
+ }
88
+ },
89
+ required: ['domChanges', 'response', 'action']
90
+ }
91
+
92
+ function spawnClaudeForConversation(conversationId, systemPrompt, sessionId, isResume = false) {
93
+ if (claudeProcesses.has(conversationId)) {
94
+ console.log(`Claude CLI already running for conversation ${conversationId}`)
95
+ return claudeProcesses.get(conversationId)
96
+ }
97
+
98
+ console.log(`Spawning Claude CLI process for conversation ${conversationId}...`)
99
+ const args = [
100
+ '@anthropic-ai/claude-code',
101
+ '--print',
102
+ '--verbose',
103
+ '--output-format', 'stream-json',
104
+ '--input-format', 'stream-json',
105
+ '--replay-user-messages',
106
+ '--permission-mode', 'plan',
107
+ '--tools', '',
108
+ '--settings', JSON.stringify({ disableClaudeMd: true })
109
+ ]
110
+
111
+ if (sessionId) {
112
+ if (isResume) {
113
+ console.log(`Resuming session ${sessionId} for conversation ${conversationId}`)
114
+ args.push('--resume', sessionId)
115
+ } else {
116
+ console.log(`Starting new session ${sessionId} for conversation ${conversationId}`)
117
+ args.push('--session-id', sessionId)
118
+ }
119
+ }
120
+
121
+ if (systemPrompt) {
122
+ console.log(`Using custom system prompt for conversation ${conversationId}`)
123
+ args.push('--system-prompt', systemPrompt)
124
+ }
125
+
126
+ // Add JSON schema for structured output
127
+ console.log(`Adding JSON schema for structured DOM changes output`)
128
+ args.push('--json-schema', JSON.stringify(DOM_CHANGES_SCHEMA))
129
+
130
+ console.log(`[${conversationId}] ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`)
131
+ console.log(`[${conversationId}] šŸš€ SPAWNING CLAUDE CLI WITH ARGUMENTS:`)
132
+ console.log(`[${conversationId}] Command: npx ${args.join(' ')}`)
133
+ console.log(`[${conversationId}] Using --json-schema for structured output`)
134
+ console.log(`[${conversationId}] ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`)
135
+
136
+ const claudeProcess = spawn('npx', args, {
137
+ stdio: ['pipe', 'pipe', 'pipe']
138
+ })
139
+
140
+ claudeProcess.stdout.on('data', (data) => {
141
+ let buffer = outputBuffers.get(conversationId) || ''
142
+ buffer += data.toString()
143
+
144
+ const lines = buffer.split('\n')
145
+ buffer = lines.pop()
146
+ outputBuffers.set(conversationId, buffer)
147
+
148
+ for (const line of lines) {
149
+ if (!line.trim()) continue
150
+
151
+ try {
152
+ const event = JSON.parse(line)
153
+ console.log(`[${conversationId}] ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`)
154
+ console.log(`[${conversationId}] šŸ“¦ RAW EVENT FROM CLAUDE CLI:`)
155
+ console.log(JSON.stringify(event, null, 2))
156
+ console.log(`[${conversationId}] ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`)
157
+
158
+ const res = activeStreams.get(conversationId)
159
+ if (res) {
160
+ if (event.type === 'assistant' && event.message?.content) {
161
+ console.log(`[${conversationId}] Processing assistant message with ${event.message.content.length} content blocks`)
162
+ for (const block of event.message.content) {
163
+ console.log(`[${conversationId}] Content block type: ${block.type}`)
164
+ if (block.type === 'text' && block.text) {
165
+ // Try to parse as JSON schema response
166
+ try {
167
+ const parsedJson = JSON.parse(block.text.trim())
168
+ // Check if it matches our schema (has required fields)
169
+ if (parsedJson.domChanges && parsedJson.response && parsedJson.action) {
170
+ console.log(`[${conversationId}] āœ… Parsed JSON schema response, forwarding as structured data`)
171
+ console.log(`[${conversationId}] Structured data:`, JSON.stringify(parsedJson, null, 2))
172
+ // Send as tool_use-style event for compatibility
173
+ res.write(`data: ${JSON.stringify({ type: 'tool_use', data: parsedJson })}\n\n`)
174
+ // Also send the response text for display
175
+ if (parsedJson.response) {
176
+ res.write(`data: ${JSON.stringify({ type: 'text', data: parsedJson.response })}\n\n`)
177
+ }
178
+ } else {
179
+ // Not our schema format, send as regular text
180
+ res.write(`data: ${JSON.stringify({ type: 'text', data: block.text })}\n\n`)
181
+ }
182
+ } catch (e) {
183
+ // Not JSON, send as regular text
184
+ res.write(`data: ${JSON.stringify({ type: 'text', data: block.text })}\n\n`)
185
+ }
186
+ } else if (block.type === 'tool_use' && block.input) {
187
+ // Handle tool_use blocks (shouldn't happen with --json-schema, but keep for safety)
188
+ console.log(`[${conversationId}] āœ… Found tool_use block, forwarding to client`)
189
+ console.log(`[${conversationId}] Tool input:`, JSON.stringify(block.input, null, 2))
190
+ const toolInput = block.input
191
+ res.write(`data: ${JSON.stringify({ type: 'tool_use', data: toolInput })}\n\n`)
192
+ if (toolInput.response) {
193
+ res.write(`data: ${JSON.stringify({ type: 'text', data: toolInput.response })}\n\n`)
194
+ }
195
+ } else {
196
+ console.log(`[${conversationId}] āš ļø Unknown or unhandled content block type:`, block.type)
197
+ }
198
+ }
199
+ } else if (event.type === 'result') {
200
+ console.log(`[${conversationId}] Received result event - sending done`)
201
+ // Don't send result as text - we already sent the assistant message content
202
+ // Just signal that we're done
203
+ res.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`)
204
+ res.end()
205
+ activeStreams.delete(conversationId)
206
+ outputBuffers.delete(conversationId)
207
+ } else if (event.type === 'error') {
208
+ res.write(`data: ${JSON.stringify({ type: 'error', data: event.error || 'Unknown error' })}\n\n`)
209
+ res.end()
210
+ activeStreams.delete(conversationId)
211
+ outputBuffers.delete(conversationId)
212
+ } else {
213
+ res.write(`data: ${JSON.stringify(event)}\n\n`)
214
+ }
215
+ }
216
+ } catch (err) {
217
+ console.error(`[${conversationId}] Failed to parse Claude output:`, err.message, 'Raw:', line.substring(0, 200))
218
+
219
+ const res = activeStreams.get(conversationId)
220
+ if (res) {
221
+ res.write(`data: ${JSON.stringify({
222
+ type: 'text',
223
+ data: 'Response generated but encountered parsing error. Check server logs for details.'
224
+ })}\n\n`)
225
+ res.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`)
226
+ res.end()
227
+ activeStreams.delete(conversationId)
228
+ outputBuffers.delete(conversationId)
229
+ }
230
+ }
231
+ }
232
+ })
233
+
234
+ claudeProcess.stderr.on('data', (data) => {
235
+ console.error(`[${conversationId}] Claude CLI stderr:`, data.toString())
236
+ })
237
+
238
+ claudeProcess.on('exit', (code) => {
239
+ console.log(`[${conversationId}] Claude CLI exited with code ${code}`)
240
+ claudeProcesses.delete(conversationId)
241
+ outputBuffers.delete(conversationId)
242
+ const res = activeStreams.get(conversationId)
243
+ if (res) {
244
+ res.end()
245
+ activeStreams.delete(conversationId)
246
+ }
247
+ })
248
+
249
+ claudeProcesses.set(conversationId, claudeProcess)
250
+ return claudeProcess
251
+ }
252
+
253
+ app.get('/health', (req, res) => {
254
+ const authStatus = checkClaudeAuth()
255
+ res.json({
256
+ ok: true,
257
+ authenticated: authStatus.authenticated,
258
+ claudeProcesses: claudeProcesses.size,
259
+ ...authStatus
260
+ })
261
+ })
262
+
263
+ app.get('/auth/status', (req, res) => {
264
+ const authStatus = checkClaudeAuth()
265
+ res.json(authStatus)
266
+ })
267
+
268
+ app.post('/conversations', (req, res) => {
269
+ const { session_id } = req.body
270
+ const conversationId = session_id || `conv_${Date.now()}`
271
+
272
+ conversationMessages.set(conversationId, [])
273
+
274
+ if (session_id) {
275
+ const isResume = sessionTracking.has(session_id)
276
+ sessionTracking.set(session_id, { conversationId, isResume })
277
+ console.log(`Conversation ${conversationId} ${isResume ? 'resuming' : 'starting'} session ${session_id}`)
278
+ }
279
+
280
+ res.json({
281
+ success: true,
282
+ conversationId
283
+ })
284
+ })
285
+
286
+ app.post('/conversations/:id/messages', (req, res) => {
287
+ const { id } = req.params
288
+ const { content, files, systemPrompt } = req.body
289
+
290
+ if (!claudeProcesses.has(id)) {
291
+ let sessionId = null
292
+ let isResume = false
293
+
294
+ for (const [sid, info] of sessionTracking.entries()) {
295
+ if (info.conversationId === id) {
296
+ sessionId = sid
297
+ isResume = info.isResume
298
+ break
299
+ }
300
+ }
301
+
302
+ spawnClaudeForConversation(id, systemPrompt, sessionId, isResume)
303
+ setTimeout(() => {
304
+ sendUserMessage(id, content, files)
305
+ }, 1000)
306
+ } else {
307
+ sendUserMessage(id, content, files)
308
+ }
309
+
310
+ res.json({
311
+ success: true
312
+ })
313
+ })
314
+
315
+ function sendUserMessage(conversationId, content, files) {
316
+ const claudeProcess = claudeProcesses.get(conversationId)
317
+ if (!claudeProcess) {
318
+ console.error(`[${conversationId}] No Claude process found`)
319
+ return
320
+ }
321
+
322
+ const messages = conversationMessages.get(conversationId) || []
323
+ messages.push({ role: 'user', content })
324
+ conversationMessages.set(conversationId, messages)
325
+
326
+ let messageContent
327
+
328
+ if (files && files.length > 0) {
329
+ messageContent = [{ type: 'text', text: content }]
330
+
331
+ for (const file of files) {
332
+ const match = file.match(/^data:image\/(\w+);base64,(.+)$/)
333
+ if (match) {
334
+ const [, format, data] = match
335
+ messageContent.push({
336
+ type: 'image',
337
+ source: {
338
+ type: 'base64',
339
+ media_type: `image/${format}`,
340
+ data: data
341
+ }
342
+ })
343
+ } else {
344
+ console.warn(`[${conversationId}] Invalid data URI format: ${file.substring(0, 50)}...`)
345
+ }
346
+ }
347
+ console.log(`[${conversationId}] Sending message with ${files.length} image(s)`)
348
+ } else {
349
+ messageContent = content
350
+ }
351
+
352
+ const userMessage = {
353
+ type: 'user',
354
+ message: {
355
+ role: 'user',
356
+ content: messageContent
357
+ }
358
+ }
359
+
360
+ console.log(`[${conversationId}] Sending to Claude:`, JSON.stringify(userMessage).substring(0, 200))
361
+ claudeProcess.stdin.write(JSON.stringify(userMessage) + '\n')
362
+ }
363
+
364
+ app.get('/conversations/:id/stream', (req, res) => {
365
+ const { id } = req.params
366
+
367
+ res.setHeader('Content-Type', 'text/event-stream')
368
+ res.setHeader('Cache-Control', 'no-cache')
369
+ res.setHeader('Connection', 'keep-alive')
370
+
371
+ activeStreams.set(id, res)
372
+
373
+ req.on('close', () => {
374
+ activeStreams.delete(id)
375
+ })
376
+ })
377
+
378
+ app.post('/conversations/:id/approve', (req, res) => {
379
+ const { id } = req.params
380
+ const { requestId, data } = req.body
381
+
382
+ if (!claudeProcess) {
383
+ return res.status(400).json({
384
+ error: 'Claude CLI not started'
385
+ })
386
+ }
387
+
388
+ claudeProcess.stdin.write(JSON.stringify({
389
+ type: 'control',
390
+ action: 'approve',
391
+ requestId,
392
+ data
393
+ }) + '\n')
394
+
395
+ res.json({
396
+ success: true
397
+ })
398
+ })
399
+
400
+ app.post('/conversations/:id/deny', (req, res) => {
401
+ const { id } = req.params
402
+ const { requestId, reason } = req.body
403
+
404
+ if (!claudeProcess) {
405
+ return res.status(400).json({
406
+ error: 'Claude CLI not started'
407
+ })
408
+ }
409
+
410
+ claudeProcess.stdin.write(JSON.stringify({
411
+ type: 'control',
412
+ action: 'deny',
413
+ requestId,
414
+ reason
415
+ }) + '\n')
416
+
417
+ res.json({
418
+ success: true
419
+ })
420
+ })
421
+
422
+ function tryStartServer(ports, index = 0) {
423
+ if (index >= ports.length) {
424
+ console.error(`\nāŒ Failed to start server on any port (tried ${ports.join(', ')})`)
425
+ process.exit(1)
426
+ }
427
+
428
+ const port = ports[index]
429
+ const server = app.listen(port)
430
+ .on('listening', () => {
431
+ console.log(`\nāœ… ABsmartly Claude Code Bridge running on http://localhost:${port}`)
432
+ console.log(`\nAuth Status:`)
433
+ const authStatus = checkClaudeAuth()
434
+ if (authStatus.authenticated) {
435
+ console.log(`āœ“ Authenticated (${authStatus.subscriptionType} subscription)`)
436
+ } else {
437
+ console.log(`āœ— Not authenticated`)
438
+ console.log(` ${authStatus.error}`)
439
+ console.log(`\n Run: npx @anthropic-ai/claude-code login`)
440
+ }
441
+ console.log(`\nEndpoints:`)
442
+ console.log(` GET /health`)
443
+ console.log(` GET /auth/status`)
444
+ console.log(` POST /conversations`)
445
+ console.log(` POST /conversations/:id/messages`)
446
+ console.log(` GET /conversations/:id/stream`)
447
+ console.log(` POST /conversations/:id/approve`)
448
+ console.log(` POST /conversations/:id/deny`)
449
+ console.log(`\nReady for connections from ABsmartly extension šŸš€\n`)
450
+
451
+ setupShutdownHandlers(server)
452
+ })
453
+ .on('error', (err) => {
454
+ if (err.code === 'EADDRINUSE') {
455
+ console.log(`āš ļø Port ${port} is already in use, trying next port...`)
456
+ tryStartServer(ports, index + 1)
457
+ } else {
458
+ console.error(`\nāŒ Error starting server:`, err)
459
+ process.exit(1)
460
+ }
461
+ })
462
+ }
463
+
464
+ function setupShutdownHandlers(server) {
465
+ const shutdown = () => {
466
+ console.log('\nShutting down...')
467
+ if (claudeProcess) {
468
+ claudeProcess.kill()
469
+ }
470
+ server.close(() => {
471
+ console.log('Server closed')
472
+ process.exit(0)
473
+ })
474
+ }
475
+
476
+ process.on('SIGTERM', shutdown)
477
+ process.on('SIGINT', shutdown)
478
+ }
479
+
480
+ const portsToTry = PORT ? [PORT] : PREFERRED_PORTS
481
+ tryStartServer(portsToTry)
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@absmartly/claude-code-bridge",
3
+ "version": "1.0.0",
4
+ "description": "HTTP bridge server for ABsmartly Extension to communicate with Claude Code CLI",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "claude-code-bridge": "./index.js"
8
+ },
9
+ "scripts": {
10
+ "start": "nodemon --watch index.js --watch package.json --exitcrash index.js"
11
+ },
12
+ "keywords": [
13
+ "claude",
14
+ "absmartly",
15
+ "ab-testing",
16
+ "bridge"
17
+ ],
18
+ "author": "ABsmartly",
19
+ "license": "MIT",
20
+ "dependencies": {
21
+ "express": "^4.18.2",
22
+ "cors": "^2.8.5",
23
+ "dotenv": "^16.3.1"
24
+ },
25
+ "devDependencies": {
26
+ "nodemon": "^3.0.1"
27
+ },
28
+ "engines": {
29
+ "node": ">=16.0.0"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/absmartly/claude-code-bridge.git"
37
+ },
38
+ "bugs": {
39
+ "url": "https://github.com/absmartly/claude-code-bridge/issues"
40
+ },
41
+ "homepage": "https://github.com/absmartly/claude-code-bridge#readme"
42
+ }