#!/usr/bin/env node // Claude Desktop — Bridge zwischen Tauri-Backend und Anthropic API // // Direkte API-Anbindung statt Claude CLI — kein Overhead, ~2s Antwort // // Kommunikation: stdin/stdout als NDJSON // // Eingehend (von Tauri): // { "command": "message", "id": "req-1", "message": "Fixe den Bug..." } // { "command": "stop", "id": "req-2" } // { "command": "set-api-key", "id": "req-3", "apiKey": "sk-ant-..." } // // Ausgehend (an Tauri): // { "type": "event", "event": "ready" } // { "type": "event", "event": "agent-started", "payload": {...} } // { "type": "event", "event": "text", "payload": { "text": "..." } } // { "type": "event", "event": "result", "payload": { "cost": 0.01, ... } } // { "type": "event", "event": "agent-stopped", "payload": {...} } import Anthropic from '@anthropic-ai/sdk'; import { createInterface } from 'node:readline'; import { randomUUID } from 'node:crypto'; import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; // Prozess am Leben halten — MUSS vor allem anderen stehen const keepAlive = setInterval(() => {}, 60000); process.stdin.resume(); // stdin offen halten auch ohne readline // ============ State ============ let client = null; let apiKey = null; let conversationHistory = []; // Messages für Multi-Turn let activeAbortController = null; const MODEL = process.env.CLAUDE_MODEL || 'claude-opus-4-20250514'; const SYSTEM_PROMPT = `Du bist Claude, ein hilfreicher KI-Assistent von Anthropic. Du antwortest auf Deutsch. Du bist direkt, präzise und hilfreich. Wenn du Code schreibst, nutze Kommentare auf Deutsch.`; // ============ API-Key Management ============ function getConfigPath() { return join(homedir(), '.claude', 'claude-desktop-config.json'); } function loadApiKey() { // 1. Env-Var if (process.env.ANTHROPIC_API_KEY) { return process.env.ANTHROPIC_API_KEY; } // 2. Gespeicherte Config (eigener Key) const configPath = getConfigPath(); if (existsSync(configPath)) { try { const config = JSON.parse(readFileSync(configPath, 'utf-8')); if (config.apiKey) return config.apiKey; } catch {} } // 3. Claude Code OAuth-Token (aus Claude Max Abo) const credentialsPath = join(homedir(), '.claude', '.credentials.json'); if (existsSync(credentialsPath)) { try { const creds = JSON.parse(readFileSync(credentialsPath, 'utf-8')); const oauth = creds.claudeAiOauth; if (oauth?.accessToken) { // Prüfen ob Token noch gültig if (oauth.expiresAt && oauth.expiresAt > Date.now()) { process.stderr.write(`🔑 OAuth-Token aus Claude Code geladen (gültig bis ${new Date(oauth.expiresAt).toLocaleString('de-DE')})\n`); return oauth.accessToken; } else { process.stderr.write(`⚠️ OAuth-Token abgelaufen\n`); } } } catch {} } return null; } function saveApiKey(key) { const configPath = getConfigPath(); try { const config = existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf-8')) : {}; config.apiKey = key; writeFileSync(configPath, JSON.stringify(config, null, 2)); } catch (err) { process.stderr.write(`Config speichern fehlgeschlagen: ${err.message}\n`); } } function initClient(key) { apiKey = key; client = new Anthropic({ apiKey: key }); return true; } // ============ Kommunikation mit Tauri ============ function sendToTauri(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); } function sendEvent(event, payload = {}) { sendToTauri({ type: 'event', event, payload }); } function sendResponse(id, result) { sendToTauri({ type: 'response', id, result }); } function sendError(id, error) { sendToTauri({ type: 'response', id, error }); } // ============ Claude API aufrufen ============ async function sendToAnthropic(message, requestId) { if (!client) { sendError(requestId, 'Kein API-Key gesetzt. Bitte zuerst API-Key konfigurieren.'); sendEvent('agent-stopped', { id: 'none', code: 1 }); return; } const agentId = randomUUID(); // AbortController für STOPP activeAbortController = new AbortController(); sendEvent('agent-started', { id: agentId, type: 'Main', task: message.substring(0, 100), }); sendResponse(requestId, { agentId, status: 'gestartet' }); // Nachricht zur History hinzufügen conversationHistory.push({ role: 'user', content: message }); try { const startTime = Date.now(); // Streaming Response const stream = client.messages.stream({ model: MODEL, max_tokens: 8192, system: SYSTEM_PROMPT, messages: conversationHistory, }, { signal: activeAbortController.signal, }); let fullResponse = ''; let inputTokens = 0; let outputTokens = 0; // Text-Chunks streamen stream.on('text', (text) => { fullResponse += text; sendEvent('text', { text }); }); // Warten auf vollständige Antwort const finalMessage = await stream.finalMessage(); inputTokens = finalMessage.usage?.input_tokens || 0; outputTokens = finalMessage.usage?.output_tokens || 0; // Kosten berechnen (Claude Sonnet 4 Preise) const cost = (inputTokens * 3 / 1_000_000) + (outputTokens * 15 / 1_000_000); const durationMs = Date.now() - startTime; // Antwort zur History hinzufügen conversationHistory.push({ role: 'assistant', content: fullResponse }); sendEvent('result', { text: fullResponse, cost, tokens: { input: inputTokens, output: outputTokens }, duration_ms: durationMs, model: MODEL, }); sendEvent('agent-stopped', { id: agentId, code: 0 }); } catch (err) { if (err.name === 'AbortError') { sendEvent('agent-stopped', { id: agentId, code: -1 }); } else { const errorMsg = err.message || String(err); sendEvent('text', { text: `\n\n**Fehler:** ${errorMsg}` }); sendEvent('agent-stopped', { id: agentId, code: 1 }); } } finally { activeAbortController = null; sendEvent('all-stopped'); } } // ============ Befehle verarbeiten ============ function handleCommand(msg) { switch (msg.command) { case 'message': if (!msg.message) { sendError(msg.id, 'Keine Nachricht angegeben'); return; } sendToAnthropic(msg.message, msg.id); break; case 'stop': if (activeAbortController) { activeAbortController.abort(); } sendResponse(msg.id, { status: 'gestoppt' }); break; case 'set-api-key': if (!msg.apiKey) { sendError(msg.id, 'Kein API-Key angegeben'); return; } initClient(msg.apiKey); saveApiKey(msg.apiKey); sendResponse(msg.id, { status: 'ok' }); sendEvent('api-key-set'); break; case 'clear-history': conversationHistory = []; sendResponse(msg.id, { status: 'ok', cleared: true }); break; case 'status': sendResponse(msg.id, { hasApiKey: !!apiKey, model: MODEL, historyLength: conversationHistory.length, isProcessing: !!activeAbortController, }); break; case 'ping': sendResponse(msg.id, { pong: true }); break; default: sendError(msg.id, `Unbekannter Befehl: ${msg.command}`); } } // ============ Main ============ // API-Key laden const savedKey = loadApiKey(); if (savedKey) { initClient(savedKey); } // stdin zeilenweise lesen const rl = createInterface({ input: process.stdin }); rl.on('line', (line) => { if (!line.trim()) return; try { const msg = JSON.parse(line); handleCommand(msg); } catch (err) { process.stderr.write(`Ungültige Eingabe: ${err.message}\n`); } }); // NICHT beenden wenn stdin schließt — wir warten auf weitere Befehle per stdin rl.on('close', () => { process.stderr.write('⚠️ stdin geschlossen — Bridge bleibt trotzdem aktiv\n'); }); // Sauber beenden process.on('SIGTERM', () => { clearInterval(keepAlive); process.exit(0); }); process.on('SIGINT', () => { clearInterval(keepAlive); process.exit(0); }); // Bereit-Signal sendEvent('ready', { version: '0.2.0', pid: process.pid, hasApiKey: !!apiKey, model: MODEL, });