From 54bc458b7d7d7fb35b02bc1b43ffb11f330794b8 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Wed, 28 Jan 2026 11:46:47 -0500 Subject: [PATCH] Add PTY CLI wrapper Wraps claude CLI in PTY and bridges to WebSocket server. Handles: - Argument parsing (--server, --secret) - PTY spawn with terminal size detection - Bidirectional I/O: local stdin/stdout + WebSocket - Terminal resize events (SIGWINCH) - Graceful cleanup on exit - Basic WebSocket reconnection - Auth and session messages to server --- src/cli.ts | 204 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) mode change 100644 => 100755 src/cli.ts diff --git a/src/cli.ts b/src/cli.ts old mode 100644 new mode 100755 index c74d9ed..22bf1f8 --- a/src/cli.ts +++ b/src/cli.ts @@ -1 +1,205 @@ +#!/usr/bin/env bun + // PTY wrapper for claude CLI + +import type { IPty } from "bun-pty"; +import { spawn } from "bun-pty"; +import type { ClientMessage, ServerMessage } from "./types"; + +interface Args { + server: string; + secret: string; + claudeArgs: string[]; +} + +function parseArgs(): Args { + const args = process.argv.slice(2); + let server = "ws://localhost:3000/ws"; + let secret = ""; + const claudeArgs: string[] = []; + + let i = 0; + while (i < args.length) { + if (args[i] === "--server" && i + 1 < args.length) { + server = args[i + 1] as string; + i += 2; + } else if (args[i] === "--secret" && i + 1 < args.length) { + secret = args[i + 1] as string; + i += 2; + } else if (args[i] === "--") { + claudeArgs.push(...args.slice(i + 1)); + break; + } else { + claudeArgs.push(args[i] as string); + i++; + } + } + + if (!secret) { + console.error("Error: --secret is required"); + process.exit(1); + } + + return { server, secret, claudeArgs }; +} + +function getTerminalSize(): { cols: number; rows: number } { + if (process.stdout.isTTY) { + return { + cols: process.stdout.columns || 80, + rows: process.stdout.rows || 24, + }; + } + return { cols: 80, rows: 24 }; +} + +async function main() { + const args = parseArgs(); + const { cols, rows } = getTerminalSize(); + + let pty: IPty | null = null; + let ws: WebSocket | null = null; + let isExiting = false; + let reconnectTimer: Timer | null = null; + + const cleanup = () => { + if (isExiting) return; + isExiting = true; + + if (reconnectTimer) { + clearTimeout(reconnectTimer); + } + if (pty) { + pty.kill(); + } + if (ws) { + ws.close(); + } + process.exit(0); + }; + + process.on("SIGINT", cleanup); + process.on("SIGTERM", cleanup); + + // Spawn claude with PTY + pty = spawn("claude", args.claudeArgs, { + name: "xterm-256color", + cols, + rows, + cwd: process.cwd(), + env: process.env as Record, + }); + + // Set stdin to raw mode if TTY + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + + // Forward local stdin to PTY + process.stdin.on("data", (data: Buffer) => { + if (pty) { + pty.write(data.toString()); + } + }); + + // Forward PTY output to stdout + pty.onData((data: string) => { + process.stdout.write(data); + }); + + // Handle PTY exit + pty.onExit((event) => { + if (ws && !isExiting) { + const msg: ClientMessage = { type: "exit", code: event.exitCode }; + ws.send(JSON.stringify(msg)); + } + cleanup(); + }); + + // Handle terminal resize + process.stdout.on("resize", () => { + if (pty && process.stdout.isTTY) { + const newCols = process.stdout.columns || 80; + const newRows = process.stdout.rows || 24; + pty.resize(newCols, newRows); + + if (ws && ws.readyState === WebSocket.OPEN) { + const msg: ClientMessage = { + type: "resize", + cols: newCols, + rows: newRows, + }; + ws.send(JSON.stringify(msg)); + } + } + }); + + // Connect to server + const connect = () => { + if (isExiting) return; + + ws = new WebSocket(args.server); + + ws.onopen = () => { + if (!ws) return; + + const command = `claude ${args.claudeArgs.join(" ")}`; + const msg: ClientMessage = { + type: "auth", + secret: args.secret, + cwd: process.cwd(), + command, + }; + ws.send(JSON.stringify(msg)); + }; + + ws.onmessage = (event) => { + if (!pty) return; + + try { + const msg: ServerMessage = JSON.parse(event.data); + + if (msg.type === "input") { + pty.write(msg.data); + } else if (msg.type === "resize") { + pty.resize(msg.cols, msg.rows); + } else if (msg.type === "ping") { + // Acknowledge ping (keep-alive) + } + } catch (err) { + console.error("Failed to parse server message:", err); + } + }; + + ws.onerror = () => { + console.error("WebSocket error"); + }; + + ws.onclose = () => { + if (isExiting) return; + + // Try to reconnect after 2 seconds + reconnectTimer = setTimeout(() => { + console.debug("Reconnecting to server..."); + connect(); + }, 2000); + }; + }; + + // Forward PTY output to server + if (pty) { + pty.onData((data: string) => { + if (ws && ws.readyState === WebSocket.OPEN) { + const msg: ClientMessage = { type: "output", data }; + ws.send(JSON.stringify(msg)); + } + }); + } + + connect(); +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +});