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
This commit is contained in:
Jared Miller 2026-01-28 11:46:47 -05:00
parent 65b8acf5f8
commit 54bc458b7d
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C

204
src/cli.ts Normal file → Executable file
View file

@ -1 +1,205 @@
#!/usr/bin/env bun
// PTY wrapper for claude CLI // 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<string, string>,
});
// 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);
});