Files
dev/cli-proxy/pty-proxy.js
T

222 lines
8.0 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));
});