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:
parent
65b8acf5f8
commit
54bc458b7d
1 changed files with 204 additions and 0 deletions
204
src/cli.ts
Normal file → Executable file
204
src/cli.ts
Normal file → Executable file
|
|
@ -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<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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue