mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
222 lines
8.0 KiB
JavaScript
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));
|
|
});
|