UNPKG

claude-code-mobile-terminal-server

Version:

WebSocket terminal server for Claude Code Mobile - enables mobile access to Claude Code in GitHub Codespaces

439 lines (396 loc) • 17.9 kB
const WebSocket = require('ws'); const pty = require('node-pty'); const express = require('express'); const cors = require('cors'); const http = require('http'); const path = require('path'); const fs = require('fs'); class TerminalServer { constructor(options = {}) { this.port = options.port || process.env.CC_MOBILE_PORT || 8080; this.host = options.host || process.env.CC_MOBILE_HOST || '0.0.0.0'; this.terminals = new Map(); this.server = null; this.wss = null; this.setupExpress(); this.setupWebSocket(); } setupExpress() { this.app = express(); this.app.use(cors()); this.app.use(express.json()); // Basic info page this.app.get('/', (req, res) => { res.send(` <html> <head><title>Claude Code Mobile Terminal Server</title></head> <body style="font-family: monospace; padding: 20px; background: #000; color: #0f0;"> <h1>šŸš€ Terminal Server Running</h1> <p>WebSocket: ws://${this.host}:${this.port}</p> <p><a href="/mobile" style="color: #ff0;">šŸ“± Mobile Terminal</a></p> <p><a href="/test">šŸ–„ļø Test Terminal</a></p> <p><a href="/health">šŸ’Š Health Check</a></p> </body> </html> `); }); // Health check this.app.get('/health', (req, res) => { res.json({ status: 'ok', activeTerminals: this.terminals.size, claudeCodeAvailable: this.checkClaudeAvailable(), uptime: process.uptime() }); }); // Receive structured actions from hooks and broadcast to clients this.app.post('/actions', (req, res) => { try { const token = process.env.CC_MOBILE_ACTIONS_TOKEN || ''; if (token) { const hdr = req.headers['x-cc-actions-token'] || req.headers['authorization'] || ''; const val = Array.isArray(hdr) ? hdr[0] : hdr; if (!val || (!val.includes(token) && val !== token)) { return res.status(401).json({ error: 'unauthorized' }); } } else { // If no token configured, only allow localhost const ip = req.ip || ''; const local = ip.includes('127.0.0.1') || ip === '::1' || ip.includes('::ffff:127.0.0.1'); if (!local) { return res.status(403).json({ error: 'forbidden' }); } } const { actions, terminalId } = req.body || {}; const payload = { type: 'actions', actions: Array.isArray(actions) ? actions : [] }; if (terminalId && this.terminals.has(terminalId)) { const entry = this.terminals.get(terminalId); if (entry && entry.ws && entry.ws.readyState === WebSocket.OPEN) { entry.ws.send(JSON.stringify(payload)); } } else { // Broadcast to all clients if (this.wss && this.wss.clients) { for (const client of this.wss.clients) { if (client.readyState === WebSocket.OPEN) { client.send(JSON.stringify(payload)); } } } } res.json({ ok: true, delivered: true }); } catch (e) { console.error('POST /actions error:', e); res.status(500).json({ error: 'server_error' }); } }); // Mobile terminal interface this.app.get('/mobile', (req, res) => { res.send(` <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <title>Claude Code Mobile</title> <script src="https://unpkg.com/xterm@5.3.0/lib/xterm.js"></script> <script src="https://unpkg.com/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script> <link rel="stylesheet" href="https://unpkg.com/xterm@5.3.0/css/xterm.css"> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, sans-serif; background: #0f0f0f; color: white; height: 100vh; overflow: hidden; } .container { display: flex; flex-direction: column; height: 100vh; } .header { background: linear-gradient(135deg, #3b82f6, #8b5cf6); padding: 15px; text-align: center; } .terminal-section { flex: 1; padding: 10px; overflow: hidden; } #terminal { height: 100%; width: 100%; } .controls { background: #1a1a1a; padding: 10px; display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; } .btn { background: #333; color: white; border: none; border-radius: 6px; padding: 12px 16px; font-size: 14px; cursor: pointer; min-width: 70px; } .btn:active { background: #555; transform: scale(0.95); } .btn.primary { background: #3b82f6; } .virtual-keyboard { background: #2a2a2a; padding: 15px 10px; display: none; } .virtual-keyboard.show { display: block; } .key-row { display: flex; gap: 6px; margin-bottom: 8px; justify-content: center; } .key { background: #404040; color: white; border: none; border-radius: 6px; padding: 12px 8px; font-size: 14px; cursor: pointer; min-width: 35px; text-align: center; } .key:active { background: #555; } </style> </head> <body> <div class="container"> <div class="header"> <h2>šŸš€ Claude Code Mobile</h2> <span id="status">Disconnected</span> </div> <div class="terminal-section"> <div id="terminal"></div> </div> <div class="controls"> <button class="btn primary" onclick="connect()" id="connectBtn">Connect</button> <button class="btn" onclick="cmd('ls -la')" disabled id="lsBtn">ls -la</button> <button class="btn" onclick="cmd('pwd')" disabled id="pwdBtn">pwd</button> <button class="btn" onclick="cmd('claude')" disabled id="claudeBtn">claude</button> <button class="btn" onclick="cmd('clear')" disabled id="clearBtn">clear</button> <button class="btn" onclick="toggleKeyboard()" disabled id="keyboardBtn">keyboard</button> </div> <div class="virtual-keyboard" id="keyboard"> <div class="key-row"> <div class="key" onclick="sendKey('Escape')">Esc</div> <div class="key" onclick="sendKey('Tab')">Tab</div> <div class="key" onclick="sendKey('ArrowUp')">↑</div> <div class="key" onclick="sendKey('ArrowDown')">↓</div> <div class="key" onclick="sendKey('ArrowLeft')">←</div> <div class="key" onclick="sendKey('ArrowRight')">→</div> </div> <div class="key-row"> <div class="key" onclick="sendCtrl('c')">^C</div> <div class="key" onclick="sendCtrl('z')">^Z</div> <div class="key" onclick="sendKey('Enter')">āŽ</div> <div class="key" onclick="sendKey('Backspace')">⌫</div> <div class="key" onclick="toggleKeyboard()">Hide</div> </div> </div> </div> <script> let terminal, ws, fitAddon; let isConnected = false; function init() { terminal = new Terminal({ theme: { background: '#0f0f0f', foreground: '#e5e7eb', cursor: '#3b82f6' }, fontSize: 14, fontFamily: 'monospace', cursorBlink: true }); fitAddon = new FitAddon.FitAddon(); terminal.loadAddon(fitAddon); terminal.open(document.getElementById('terminal')); setTimeout(() => fitAddon.fit(), 100); terminal.onData(data => { if (ws && isConnected) ws.send(JSON.stringify({ type: 'input', data })); }); terminal.writeln('šŸš€ Claude Code Mobile Terminal'); terminal.writeln('šŸ“± Touch-optimized interface'); terminal.writeln(''); terminal.writeln('Click "Connect" to start'); } function connect() { if (isConnected) return; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; let wsUrl = protocol + '//' + window.location.host + '/?program=claude'; document.getElementById('status').textContent = 'Connecting...'; terminal.writeln('\\r\\nConnecting to: ' + wsUrl); ws = new WebSocket(wsUrl); ws.onopen = () => { isConnected = true; document.getElementById('status').textContent = 'Connected'; document.getElementById('connectBtn').textContent = 'Connected'; ['lsBtn', 'pwdBtn', 'claudeBtn', 'clearBtn', 'keyboardBtn'].forEach(id => { document.getElementById(id).disabled = false; }); terminal.writeln('āœ… Connected to terminal server'); }; ws.onerror = () => { document.getElementById('status').textContent = 'Connection Failed'; terminal.writeln('āŒ Connection failed'); }; ws.onclose = () => { isConnected = false; document.getElementById('status').textContent = 'Disconnected'; document.getElementById('connectBtn').textContent = 'Connect'; ['lsBtn', 'pwdBtn', 'claudeBtn', 'clearBtn', 'keyboardBtn'].forEach(id => { document.getElementById(id).disabled = true; }); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'output') terminal.write(msg.data); if (msg.type === 'connected') { terminal.writeln('\\r\\nšŸŽÆ Terminal ready'); } }; } function cmd(command) { if (ws && isConnected) { ws.send(JSON.stringify({ type: 'input', data: command + '\\r' })); } } function toggleKeyboard() { const kb = document.getElementById('keyboard'); kb.classList.toggle('show'); document.getElementById('keyboardBtn').textContent = kb.classList.contains('show') ? 'hide' : 'keyboard'; setTimeout(() => fitAddon.fit(), 100); } function sendKey(key) { if (!ws || !isConnected) return; let data; switch(key) { case 'Enter': data = '\\r'; break; case 'Backspace': data = '\\b'; break; case 'Tab': data = '\\t'; break; case 'Escape': data = '\\x1b'; break; case 'ArrowUp': data = '\\x1b[A'; break; case 'ArrowDown': data = '\\x1b[B'; break; case 'ArrowLeft': data = '\\x1b[D'; break; case 'ArrowRight': data = '\\x1b[C'; break; default: data = key; } ws.send(JSON.stringify({ type: 'input', data })); } function sendCtrl(key) { if (!ws || !isConnected) return; const code = key.charCodeAt(0) - 96; ws.send(JSON.stringify({ type: 'input', data: String.fromCharCode(code) })); } window.addEventListener('load', init); window.addEventListener('resize', () => setTimeout(() => fitAddon.fit(), 100)); </script> </body> </html> `); }); // Simple test page this.app.get('/test', (req, res) => { res.send(` <!DOCTYPE html> <html> <head> <title>Terminal Test</title> <script src="https://unpkg.com/xterm@5.3.0/lib/xterm.js"></script> <link rel="stylesheet" href="https://unpkg.com/xterm@5.3.0/css/xterm.css"> <style> body { margin: 20px; background: #000; color: white; font-family: monospace; } #terminal { height: 400px; border: 1px solid #333; } button { padding: 10px; margin: 5px; background: #333; color: white; border: none; cursor: pointer; } button:hover { background: #555; } </style> </head> <body> <h2>šŸ–„ļø Terminal Test</h2> <button onclick="connect()">Connect</button> <button onclick="testCommand('ls -la')">ls -la</button> <button onclick="testCommand('pwd')">pwd</button> <button onclick="testCommand('claude --version')">claude --version</button> <button onclick="testCommand('clear')">clear</button> <button onclick="testCommand('claude')">Start Claude</button> <div id="terminal"></div> <script> const terminal = new Terminal({ theme: { background: '#000' } }); terminal.open(document.getElementById('terminal')); let ws = null; function connect() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = protocol + '//' + window.location.host + '/?program=claude'; terminal.writeln('Connecting to: ' + wsUrl); ws = new WebSocket(wsUrl); ws.onopen = () => terminal.writeln('āœ… Connected'); ws.onerror = () => terminal.writeln('āŒ Connection failed'); ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'output') terminal.write(msg.data); if (msg.type === 'connected') terminal.writeln('\\r\\nšŸŽÆ Terminal ready'); }; terminal.onData(data => { if (ws) ws.send(JSON.stringify({ type: 'input', data })); }); } function testCommand(cmd) { if (ws) ws.send(JSON.stringify({ type: 'input', data: cmd + '\\r' })); } </script> </body> </html> `); }); } setupWebSocket() { this.server = http.createServer(this.app); this.wss = new WebSocket.Server({ server: this.server }); this.wss.on('connection', (ws, request) => { console.log('šŸ”— WebSocket connected'); const url = require('url'); const { query } = url.parse(request.url, true); const requestedProgram = (query && query.program) ? String(query.program) : ''; const terminalId = 'term-' + Date.now(); const defaultShell = process.platform === 'win32' ? 'powershell.exe' : 'bash'; let spawnProgram = defaultShell; let spawnArgs = []; if (requestedProgram) { if (requestedProgram === 'claude' && this.checkClaudeAvailable()) { spawnProgram = 'claude'; } else if (requestedProgram !== 'claude') { // Allow arbitrary program if requested; rely on PATH to resolve spawnProgram = requestedProgram; } } const ptyProcess = pty.spawn(spawnProgram, spawnArgs, { name: 'xterm-256color', cols: 80, rows: 30, cwd: process.cwd(), env: { ...process.env, PATH: `${process.env.HOME}/.local/bin:${process.env.PATH}` } }); this.terminals.set(terminalId, { ptyProcess, ws }); // Terminal output to WebSocket ptyProcess.on('data', (data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'output', data })); } }); // WebSocket input to terminal ws.on('message', (message) => { try { const msg = JSON.parse(message); if (msg.type === 'input') { ptyProcess.write(msg.data); } else if (msg.type === 'actions' && msg.actions) { // Allow client to push actions too (advanced use) const payload = { type: 'actions', actions: msg.actions }; ws.send(JSON.stringify(payload)); } else if (msg.type === 'resize') { const cols = Math.max(msg.cols || 80, 20); const rows = Math.max(msg.rows || 30, 10); ptyProcess.resize(cols, rows); } else if (msg.type === 'clear') { ptyProcess.write('\\u001b[2J\\u001b[3J\\u001b[H'); } } catch (error) { console.error('Message error:', error); } }); // Cleanup ws.on('close', () => { console.log('šŸ“ŗ WebSocket closed'); ptyProcess.kill(); this.terminals.delete(terminalId); }); // Send ready signal and expose chosen program ws.send(JSON.stringify({ type: 'connected', terminalId, program: spawnProgram })); }); } checkClaudeAvailable() { try { const { execSync } = require('child_process'); execSync('which claude', { stdio: 'ignore' }); return true; } catch (error) { return false; } } async start() { return new Promise((resolve, reject) => { this.server.listen(this.port, this.host, (error) => { if (error) { reject(error); return; } console.log(`šŸš€ Terminal Server: http://${this.host}:${this.port}`); resolve(); }); }); } } // Direct execution if (require.main === module) { const server = new TerminalServer(); server.start().catch(console.error); } module.exports = { TerminalServer };