#!/usr/bin/env tsx import { config as dotenvConfig } from 'dotenv' import { Readable } from 'stream' import type { ReadableStream as NodeReadableStream } from 'stream/web' // Load environment variables from .env.local dotenvConfig({ path: '.env.local' }) // Constants const DEFAULT_MESSAGE = 'Hello, how are you?' interface ChatApiConfig { apiUrl: string message: string chatId?: string searchMode?: 'quick' | 'adaptive' | boolean trigger?: 'submit-message' | 'regenerate-message' messageId?: string } interface UIMessage { id: string role: 'user' | 'assistant' | 'system' content?: string parts: Array<{ type: string text?: string [key: string]: any }> createdAt: Date } interface ChatPayload { chatId: string trigger: 'submit-message' | 'regenerate-message' message?: UIMessage messageId?: string isNewChat?: boolean } class ChatApiTester { private config: ChatApiConfig private validateUrl(url?: string): string | undefined { if (!url) return undefined try { const parsed = new URL(url) // Only allow localhost and local network if ( parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1' || parsed.hostname.startsWith('192.168.') || parsed.hostname.startsWith('10.0.') || parsed.hostname.startsWith('172.16.') || parsed.hostname.startsWith('172.17.') || parsed.hostname.startsWith('172.18.') || parsed.hostname.startsWith('172.19.') || parsed.hostname.startsWith('172.20.') || parsed.hostname.startsWith('172.21.') || parsed.hostname.startsWith('172.22.') || parsed.hostname.startsWith('172.23.') || parsed.hostname.startsWith('172.24.') || parsed.hostname.startsWith('172.25.') || parsed.hostname.startsWith('172.26.') || parsed.hostname.startsWith('172.27.') || parsed.hostname.startsWith('172.28.') || parsed.hostname.startsWith('172.29.') || parsed.hostname.startsWith('172.30.') || parsed.hostname.startsWith('172.31.') || parsed.hostname.endsWith('.local') ) { return url } console.error('āŒ Only local URLs are allowed for security') process.exit(1) } catch { console.error('āŒ Invalid URL format') process.exit(1) } } constructor(config: Partial = {}) { this.config = { apiUrl: this.validateUrl(config.apiUrl) || 'http://localhost:3000/api/chat', message: config.message || DEFAULT_MESSAGE, chatId: config.chatId || this.generateId(), searchMode: config.searchMode ?? 'adaptive', trigger: config.trigger || 'submit-message', messageId: config.messageId } } private generateId(): string { return `chat_${Date.now()}_${Math.random().toString(36).substring(2, 11)}` } private createUserMessage(text: string, messageId?: string): UIMessage { // Limit message length for safety const sanitizedText = text.slice(0, 10000) return { id: messageId || this.generateId(), role: 'user', content: sanitizedText, parts: [ { type: 'text', text: sanitizedText } ], createdAt: new Date() } } private loadCookiesFromEnv(): string | undefined { if (process.env.MORPHIC_COOKIES) { console.log('šŸŖ Using cookies from MORPHIC_COOKIES environment variable') return process.env.MORPHIC_COOKIES } return undefined } async sendMessage(message?: string): Promise { // Load cookies from env const cookies = this.loadCookiesFromEnv() const payload: ChatPayload = { chatId: this.config.chatId!, trigger: this.config.trigger || 'submit-message' } // Add message for submit trigger or messageId for regenerate if (this.config.trigger === 'regenerate-message') { if (!this.config.messageId) { console.error('āŒ Message ID is required for regeneration') process.exit(1) } payload.messageId = this.config.messageId // Only include message if we're editing a user message if (message && message !== DEFAULT_MESSAGE) { // Use the same messageId for the edited message const userMessage = this.createUserMessage( message, this.config.messageId ) payload.message = userMessage } } else { const userMessage = this.createUserMessage(message || this.config.message) payload.message = userMessage // Add isNewChat flag - always true for CLI since we generate new chatId each time payload.isNewChat = true } console.log('šŸš€ Sending request to:', this.config.apiUrl) console.log('šŸ“¦ Payload:', JSON.stringify(payload, null, 2)) console.log('šŸ” Search Mode:', this.config.searchMode) console.log('šŸ’¬ Chat ID:', this.config.chatId) console.log('\n---\n') // Build cookie string let cookieString = '' if (cookies) { // If cookies from env exist, append our settings to them cookieString = cookies if (!cookieString.includes('searchMode=')) { const searchModeValue = this.config.searchMode === false ? 'disabled' : this.config.searchMode cookieString += `; searchMode=${searchModeValue}` } } else { // If no cookies from env, just use our settings const searchModeValue = this.config.searchMode === false ? 'disabled' : this.config.searchMode cookieString = [`searchMode=${searchModeValue}`].join('; ') } const headers = { 'Content-Type': 'application/json', Cookie: cookieString } // Only show headers in debug mode, without sensitive data if (process.env.DEBUG) { console.log('šŸ” Request headers:') console.log(` Content-Type: ${headers['Content-Type']}`) console.log(` Cookie: [REDACTED]`) } try { const response = await fetch(this.config.apiUrl, { method: 'POST', headers, body: JSON.stringify(payload) }) if (!response.ok) { const errorText = await response.text() throw new Error( `HTTP ${response.status}: ${response.statusText}\n${errorText}` ) } if (!response.body) { throw new Error('No response body') } console.log('šŸ“” Response received, starting stream...\n') // Convert Web Streams API to Node.js stream // Type assertion needed due to Node.js/Web Streams API type mismatch // response.body is guaranteed to be a ReadableStream at this point const webStream = response.body as unknown as NodeReadableStream const nodeReadable = Readable.fromWeb(webStream) // Create parser stream // Process SSE stream const textDecoder = new TextDecoder() let buffer = '' let hasReceivedData = false for await (const chunk of nodeReadable) { buffer += textDecoder.decode(chunk, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() || '' for (const line of lines) { if (line.trim() === '') continue // Debug: log raw lines if (process.env.DEBUG) { console.log(`šŸ” Raw line: ${line}`) } if (line.startsWith('data: ')) { hasReceivedData = true const data = line.slice(6) if (data === '[DONE]') { console.log('\n\nāœ… Stream finished') continue } try { const parsed = JSON.parse(data) if (parsed.type === 'text') { process.stdout.write(parsed.text || '') } else if (parsed.type === 'text-delta') { process.stdout.write(parsed.delta || '') } else if (parsed.type === 'reasoning') { console.log(`\nšŸ¤” Reasoning: ${parsed.text}`) } else if (parsed.type?.startsWith('tool-')) { console.log(`\nšŸ”§ Tool: ${parsed.type}`) console.log(` State: ${parsed.state}`) if (parsed.input) { console.log(` Input: ${JSON.stringify(parsed.input)}`) } if (parsed.output) { console.log(` Output: ${JSON.stringify(parsed.output)}`) } } else if (parsed.type === 'finish') { console.log('\n\nāœ… Stream completed') } else if (parsed.type === 'error') { console.error( `\nāŒ Error: ${parsed.error || parsed.errorText || JSON.stringify(parsed)}` ) } else if (process.env.DEBUG) { // Only show raw event types in debug mode console.log(`\nšŸ“¦ Event type: ${parsed.type || 'unknown'}`) } } catch (error) { if (process.env.DEBUG) { console.log(`šŸ“„ Parse error for data`) } } } else if (line.startsWith('event: ')) { const eventName = line.slice(7) console.log(`šŸ“Ø Event: ${eventName}`) } } } if (!hasReceivedData) { console.log('\nāš ļø No data received from stream') } console.log('\n\n✨ Request completed successfully') } catch (error) { console.error( '\nāŒ Error:', error instanceof Error ? error.message : error ) process.exit(1) } } } // Parse command line arguments function parseArgs(): Partial { const args = process.argv.slice(2) const config: Partial = {} for (let i = 0; i < args.length; i++) { switch (args[i]) { case '-m': case '--message': config.message = args[++i] break case '-u': case '--url': config.apiUrl = args[++i] break case '-c': case '--chat-id': config.chatId = args[++i] break case '-s': case '--search': config.searchMode = 'adaptive' break case '--no-search': config.searchMode = false break case '--search-mode': const searchMode = args[++i] if (['quick', 'adaptive'].includes(searchMode)) { config.searchMode = searchMode as 'quick' | 'adaptive' } else { console.error('āŒ Invalid search mode. Use: quick or adaptive') process.exit(1) } break case '-t': case '--trigger': const trigger = args[++i] if (trigger === 'regenerate' || trigger === 'regenerate-message') { config.trigger = 'regenerate-message' } else { config.trigger = 'submit-message' } break case '--message-id': config.messageId = args[++i] break case '-h': case '--help': console.log(` Chat API Test Script Usage: bun scripts/chat-cli.ts [options] Options: -m, --message Message to send (default: "Hello, how are you?") -u, --url API URL (default: http://localhost:3000/api/chat) -c, --chat-id Chat ID (default: auto-generated) -s, --search Enable search mode with adaptive strategy (default) --no-search Disable search mode --search-mode Search strategy: quick or adaptive -t, --trigger Trigger type: submit (default) or regenerate --message-id Message ID (required for regenerate) -h, --help Show this help message Examples: # Simple test bun scripts/chat-cli.ts -m "What is the weather like?" # Without search mode bun scripts/chat-cli.ts -m "Tell me a joke" --no-search # Continue existing chat bun scripts/chat-cli.ts -c "chat_123" -m "Tell me more" # Regenerate assistant message bun scripts/chat-cli.ts -c "chat_123" -t regenerate --message-id "msg_123" # Edit user message and regenerate bun scripts/chat-cli.ts -c "chat_123" -t regenerate --message-id "msg_123" -m "New message" Note: Without authentication, you may get "User not authenticated" errors. Authentication: 1. Add to .env.local: MORPHIC_COOKIES="your-cookie-string" 2. The script will automatically load cookies when it runs To get cookies: 1. Open DevTools > Network tab 2. Make any request in Morphic 3. Click on the request > Headers > Request Headers > Cookie 4. Copy the entire Cookie value 5. Add to .env.local as MORPHIC_COOKIES="copied-value" `) process.exit(0) } } return config } // Main execution async function main() { const config = parseArgs() const tester = new ChatApiTester(config) await tester.sendMessage(config.message) } // Run the script main().catch(error => { console.error('Fatal error:', error) process.exit(1) })