Files
dev/cli-proxy/pty-proxy.js
T
zeekay 5f999f2519 fix(ci): fix codespell and prettier issues
- Fix typos: parellel→parallel, neccessary→necessary, mulitple→multiple,
  responible→responsible, geniune→genuine, yeild→yield
- Add ignore words: Nd, nd, edn, lightening (valid terms)
- Run prettier --write to fix all formatting issues
2026-02-05 22:17:28 -08:00

239 lines
7.4 KiB
JavaScript

#!/usr/bin/env node
/**
* Claude CLI PTY Proxy
*
* Uses PTY to interact with Claude CLI in interactive mode,
* which uses your Claude subscription instead of API credits.
*/
const http = require("http");
const pty = require("node-pty");
const PORT = parseInt(process.env.PORT || "9999");
// Track active PTY sessions
const sessions = new Map();
/**
* Send a message to Claude via PTY and get response
*/
function sendToClaude(prompt) {
return new Promise((resolve, reject) => {
const timeout = 120000; // 2 minutes
let output = "";
let responseStarted = false;
let lastDataTime = Date.now();
// Spawn Claude in interactive mode
const proc = pty.spawn("claude", ["--dangerously-skip-permissions"], {
name: "xterm-256color",
cols: 120,
rows: 40,
cwd: process.cwd(),
env: {
...process.env,
TERM: "xterm-256color",
NO_COLOR: "1", // Disable colors for easier parsing
},
});
const timeoutId = setTimeout(() => {
proc.kill();
reject(new Error("Timeout waiting for Claude response"));
}, timeout);
// Detect when Claude is ready for input
let ready = false;
let sentPrompt = false;
proc.onData((data) => {
lastDataTime = Date.now();
output += data;
// Debug logging
if (process.env.DEBUG) {
console.log("[PTY DATA]:", JSON.stringify(data.substring(0, 100)));
}
// Detect ready state (Claude shows prompt)
if (!ready && (output.includes(">") || output.includes("Claude"))) {
ready = true;
}
// Send prompt once ready
if (ready && !sentPrompt) {
sentPrompt = true;
setTimeout(() => {
proc.write(prompt + "\n");
}, 500);
}
// Detect response completion (new prompt appears after response)
if (sentPrompt && output.split(prompt).length > 1) {
// Look for completion indicators
const afterPrompt = output.split(prompt).pop();
if (afterPrompt && afterPrompt.includes(">")) {
// Response complete
clearTimeout(timeoutId);
proc.kill();
// Extract response between prompt and next >
const response = extractResponse(output, prompt);
resolve(response);
}
}
});
proc.onExit(({ exitCode }) => {
clearTimeout(timeoutId);
if (exitCode !== 0 && !output) {
reject(new Error(`Claude exited with code ${exitCode}`));
} else {
const response = extractResponse(output, prompt);
resolve(response || "No response received");
}
});
});
}
/**
* Extract Claude's response from PTY output
*/
function extractResponse(output, prompt) {
// Remove ANSI escape codes
const clean = output
.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "")
.replace(/\x1B\][^\x07]*\x07/g, "")
.replace(/[\x00-\x1F]/g, " ");
// Find the response after the prompt
const promptIndex = clean.indexOf(prompt);
if (promptIndex === -1) return clean.trim();
let response = clean.substring(promptIndex + prompt.length);
// Remove trailing prompt indicator
response = response.replace(/>\s*$/, "").trim();
// Remove any leading/trailing whitespace and common artifacts
response = response
.split("\n")
.filter(
(line) =>
line.trim() && !line.includes("Claude") && !line.startsWith(">"),
)
.join("\n")
.trim();
return response || "Response received";
}
/**
* Build prompt from messages
*/
function buildPrompt(messages) {
const lastUserMsg = messages.filter((m) => m.role === "user").pop();
return lastUserMsg?.content || messages[messages.length - 1]?.content || "";
}
/**
* HTTP request handler
*/
async function handleRequest(req, res) {
const url = new URL(req.url, `http://localhost:${PORT}`);
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method === "OPTIONS") {
res.writeHead(200);
res.end();
return;
}
if (url.pathname === "/health" || url.pathname === "/") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok", mode: "pty-interactive" }));
return;
}
if (url.pathname === "/v1/chat/completions" && req.method === "POST") {
let body = "";
req.on("data", (chunk) => {
body += chunk.toString();
});
req.on("end", async () => {
try {
const request = JSON.parse(body);
const prompt = buildPrompt(request.messages || []);
console.log(
`[${new Date().toISOString()}] PTY Request: ${prompt.substring(0, 50)}...`,
);
const response = await sendToClaude(prompt);
console.log(
`[${new Date().toISOString()}] PTY Response: ${response.substring(0, 100)}...`,
);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
id: `chatcmpl-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: request.model || "claude-interactive",
choices: [
{
index: 0,
message: { role: "assistant", content: response },
finish_reason: "stop",
},
],
usage: {
prompt_tokens: Math.ceil(prompt.length / 4),
completion_tokens: Math.ceil(response.length / 4),
total_tokens: Math.ceil((prompt.length + response.length) / 4),
},
}),
);
} catch (error) {
console.error(`[${new Date().toISOString()}] Error:`, error.message);
res.writeHead(500, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
error: { message: error.message, type: "server_error" },
}),
);
}
});
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: { message: "Not found" } }));
}
const server = http.createServer(handleRequest);
server.listen(PORT, () => {
console.log(`
╔══════════════════════════════════════════════════════════════╗
║ Claude CLI PTY Proxy (Interactive Mode) ║
╠══════════════════════════════════════════════════════════════╣
║ Listening: http://localhost:${PORT} ║
║ Mode: PTY Interactive (uses subscription, not API) ║
╠══════════════════════════════════════════════════════════════╣
║ This proxy spawns Claude CLI in interactive mode via PTY, ║
║ which uses your Claude subscription instead of API credits. ║
╚══════════════════════════════════════════════════════════════╝
`);
});
process.on("SIGINT", () => {
console.log("\nShutting down...");
server.close(() => process.exit(0));
});