using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; namespace Gott.Services; /// /// Service für die Kommunikation mit der Claude Code CLI. /// Verwendet die CLI als Backend für OAuth-basierte Chats (kein API-Key nötig). /// public class ClaudeCliService { private readonly ILogger _logger; private readonly string _cliPath; private readonly bool _isAvailable; public ClaudeCliService(ILogger logger) { _logger = logger; _cliPath = FindCliPath(); _isAvailable = !string.IsNullOrEmpty(_cliPath) && File.Exists(_cliPath); if (_isAvailable) _logger.LogInformation("Claude CLI gefunden: {Path}", _cliPath); else _logger.LogWarning("Claude CLI nicht gefunden. CLI-basierte Chats sind nicht verfügbar."); } public bool IsAvailable => _isAvailable; public string CliPath => _cliPath; private static string FindCliPath() { var possiblePaths = new[] { Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "claude"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "claude.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "claude-code", "claude.exe"), "/usr/local/bin/claude", "/usr/bin/claude" }; foreach (var path in possiblePaths) { if (File.Exists(path)) return path; } try { var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? ""; foreach (var dir in pathEnv.Split(Path.PathSeparator)) { var exePath = Path.Combine(dir, "claude"); if (File.Exists(exePath)) return exePath; exePath = Path.Combine(dir, "claude.exe"); if (File.Exists(exePath)) return exePath; } } catch { } return null; } /// /// Streaming-Chat über die Claude CLI mit Event-basiertem Callback. /// Unterstützt Text-Streaming und Tool-Use Events. /// public async Task ChatStreamingAsync( string prompt, Func onEvent, string modelId = null, string systemPrompt = null, string workspace = null, bool hasWorkspace = false, bool planMode = false, bool webSearch = false, CancellationToken cancellationToken = default) { if (!_isAvailable) return new CliChatResult { Success = false, Error = "Claude CLI nicht verfügbar" }; var args = new List { "-p", "--verbose", "--output-format", "stream-json", "--include-partial-messages", "--no-session-persistence", "--permission-mode", "acceptEdits", "--disallowed-tools", "AskUserQuestion" }; // Tools konfigurieren var tools = new List(); if (hasWorkspace && planMode) { // Plan-Modus: nur read-only Tools tools.AddRange(new[] { "Read", "Glob", "Grep" }); } else if (hasWorkspace) { // Normaler Modus mit Workspace: alle File-Tools tools.AddRange(new[] { "Bash(*)", "Read", "Write", "Edit", "Glob", "Grep" }); } if (webSearch) { tools.Add("WebFetch"); tools.Add("WebSearch"); } args.Add("--allowed-tools"); args.Add(tools.Count > 0 ? string.Join(" ", tools) : "\"\""); if (!string.IsNullOrEmpty(workspace)) { args.Add("--add-dir"); args.Add(workspace); } if (!string.IsNullOrEmpty(modelId)) { args.Add("--model"); args.Add(modelId); } if (!string.IsNullOrEmpty(systemPrompt)) { args.Add("--system-prompt"); args.Add(systemPrompt); } return await RunCliStreamingAsync(args, prompt, onEvent, cancellationToken); } private async Task RunCliStreamingAsync( List args, string stdinPrompt, Func onEvent, CancellationToken cancellationToken) { var result = new CliChatResult(); var fullOutput = new StringBuilder(); var error = new StringBuilder(); // Tracking für content blocks string currentBlockType = null; // "text" oder "tool_use" string currentToolName = null; string currentToolId = null; var currentToolInput = new StringBuilder(); bool inTextBlock = false; // Tracking für ask_user_choice Marker-Erkennung var lineBuffer = new StringBuilder(); bool inChoiceCapture = false; var choiceJsonBuffer = new StringBuilder(); bool userChoiceDetected = false; try { var startInfo = new ProcessStartInfo { FileName = _cliPath, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8 }; foreach (var arg in args) startInfo.ArgumentList.Add(arg); using var process = new Process { StartInfo = startInfo }; process.ErrorDataReceived += (s, e) => { if (e.Data != null) { error.AppendLine(e.Data); _logger.LogDebug("CLI stderr: {Line}", e.Data); } }; process.Start(); process.BeginErrorReadLine(); await process.StandardInput.WriteAsync(stdinPrompt); await process.StandardInput.FlushAsync(); process.StandardInput.Close(); using var reader = process.StandardOutput; string line; while ((line = await reader.ReadLineAsync(cancellationToken)) != null) { if (cancellationToken.IsCancellationRequested) { try { process.Kill(entireProcessTree: true); } catch { } throw new OperationCanceledException(); } if (!line.StartsWith("{")) continue; try { var json = JsonSerializer.Deserialize(line); var type = json.TryGetProperty("type", out var t) ? t.GetString() : null; if (type == "stream_event" && json.TryGetProperty("event", out var evt)) { var evtType = evt.TryGetProperty("type", out var et) ? et.GetString() : null; switch (evtType) { case "content_block_start": { if (evt.TryGetProperty("content_block", out var cb)) { var cbType = cb.TryGetProperty("type", out var cbt) ? cbt.GetString() : null; if (cbType == "text") { currentBlockType = "text"; if (!inTextBlock) { inTextBlock = true; await onEvent(new CliStreamEvent { Type = CliEventType.TextStart }); } } else if (cbType == "tool_use") { // Text-Block vorher abschließen if (inTextBlock) { inTextBlock = false; await onEvent(new CliStreamEvent { Type = CliEventType.TextEnd }); } currentBlockType = "tool_use"; currentToolName = cb.TryGetProperty("name", out var n) ? n.GetString() : "unknown"; currentToolId = cb.TryGetProperty("id", out var id) ? id.GetString() : ""; currentToolInput.Clear(); await onEvent(new CliStreamEvent { Type = CliEventType.ToolStart, ToolName = currentToolName, ToolId = currentToolId }); } } break; } case "content_block_delta": { if (evt.TryGetProperty("delta", out var delta)) { var deltaType = delta.TryGetProperty("type", out var dt) ? dt.GetString() : null; if (deltaType == "text_delta" && delta.TryGetProperty("text", out var txt)) { var text = txt.GetString(); if (!string.IsNullOrEmpty(text) && !userChoiceDetected) { // Zeilenbasierte Marker-Erkennung für :::ASK_USER_CHOICE::: for (int ci = 0; ci < text.Length; ci++) { var ch = text[ci]; if (ch == '\n') { var completedLine = lineBuffer.ToString().Trim(); lineBuffer.Clear(); if (inChoiceCapture) { if (completedLine == ":::END_ASK_USER_CHOICE:::") { // JSON parsen und UserChoice-Event emittieren try { var choiceJson = JsonSerializer.Deserialize(choiceJsonBuffer.ToString()); var question = choiceJson.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : ""; var choices = new List(); if (choiceJson.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array) foreach (var cOpt in cc.EnumerateArray()) choices.Add(cOpt.GetString() ?? ""); userChoiceDetected = true; await onEvent(new CliStreamEvent { Type = CliEventType.UserChoice, ChoiceQuestion = question, ChoiceOptions = choices }); } catch (JsonException) { // Fehlerhaftes JSON → als normalen Text weiterleiten var fallback = $":::ASK_USER_CHOICE:::\n{choiceJsonBuffer}\n:::END_ASK_USER_CHOICE:::\n"; fullOutput.Append(fallback); await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = fallback }); } inChoiceCapture = false; choiceJsonBuffer.Clear(); } else { choiceJsonBuffer.AppendLine(completedLine); } } else if (completedLine == ":::ASK_USER_CHOICE:::") { inChoiceCapture = true; choiceJsonBuffer.Clear(); } else if (completedLine.Length > 0 || fullOutput.Length > 0) { // Normale Zeile → Text weiterleiten (inkl. Newline) // Leere Zeilen am Anfang überspringen (vor dem eigentlichen Text) var lineText = completedLine + "\n"; fullOutput.Append(lineText); await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = lineText }); } } else { lineBuffer.Append(ch); } } } } else if (deltaType == "input_json_delta" && delta.TryGetProperty("partial_json", out var pj)) { currentToolInput.Append(pj.GetString()); } } break; } case "content_block_stop": { if (currentBlockType == "tool_use") { await onEvent(new CliStreamEvent { Type = CliEventType.ToolEnd, ToolName = currentToolName, ToolId = currentToolId, ToolInput = currentToolInput.ToString() }); currentBlockType = null; currentToolName = null; currentToolId = null; currentToolInput.Clear(); } else if (currentBlockType == "text") { currentBlockType = null; // text_end wird erst beim nächsten tool_use oder am Ende gesendet } break; } } } else if (type == "result") { // Restlichen lineBuffer flushen (nicht-abgeschlossene Zeile) if (lineBuffer.Length > 0 && !userChoiceDetected) { var remaining = lineBuffer.ToString().Trim(); lineBuffer.Clear(); if (inChoiceCapture && remaining == ":::END_ASK_USER_CHOICE:::") { // End-Marker ohne trailing \n → Capture abschließen try { var choiceJson = JsonSerializer.Deserialize(choiceJsonBuffer.ToString()); var question = choiceJson.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : ""; var choices = new List(); if (choiceJson.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array) foreach (var cOpt in cc.EnumerateArray()) choices.Add(cOpt.GetString() ?? ""); userChoiceDetected = true; await onEvent(new CliStreamEvent { Type = CliEventType.UserChoice, ChoiceQuestion = question, ChoiceOptions = choices }); } catch (JsonException) { var fallback = $":::ASK_USER_CHOICE:::\n{choiceJsonBuffer}\n{remaining}\n"; fullOutput.Append(fallback); await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = fallback }); } inChoiceCapture = false; choiceJsonBuffer.Clear(); } else if (!inChoiceCapture) { fullOutput.Append(remaining); await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining }); } } // Offenen Text-Block abschließen if (inTextBlock) { inTextBlock = false; await onEvent(new CliStreamEvent { Type = CliEventType.TextEnd }); } if (json.TryGetProperty("usage", out var usage)) { result.InputTokens = usage.TryGetProperty("input_tokens", out var it) ? it.GetInt64() : 0; result.OutputTokens = usage.TryGetProperty("output_tokens", out var ot) ? ot.GetInt64() : 0; result.CacheReadTokens = usage.TryGetProperty("cache_read_input_tokens", out var cr) ? cr.GetInt64() : 0; result.CacheCreationTokens = usage.TryGetProperty("cache_creation_input_tokens", out var cc) ? cc.GetInt64() : 0; } if (json.TryGetProperty("total_cost_usd", out var cost)) result.CostUsd = cost.GetDouble(); if (json.TryGetProperty("duration_api_ms", out var dur)) result.DurationApiMs = dur.GetInt64(); if (json.TryGetProperty("session_id", out var sid)) result.SessionId = sid.GetString(); if (json.TryGetProperty("num_turns", out var nt)) result.NumTurns = nt.GetInt32(); var isError = json.TryGetProperty("is_error", out var ie) && ie.GetBoolean(); if (isError) result.Error = json.TryGetProperty("result", out var errMsg) ? errMsg.GetString() : "CLI error"; } } catch (JsonException ex) { _logger.LogDebug("CLI JSON parse error: {Error} for line: {Line}", ex.Message, line.Length > 200 ? line[..200] : line); } } // Restlichen lineBuffer flushen if (lineBuffer.Length > 0 && !userChoiceDetected) { var remaining = lineBuffer.ToString().Trim(); lineBuffer.Clear(); if (inChoiceCapture && remaining == ":::END_ASK_USER_CHOICE:::") { try { var choiceJson = JsonSerializer.Deserialize(choiceJsonBuffer.ToString()); var question = choiceJson.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : ""; var choices = new List(); if (choiceJson.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array) foreach (var cOpt in cc.EnumerateArray()) choices.Add(cOpt.GetString() ?? ""); userChoiceDetected = true; await onEvent(new CliStreamEvent { Type = CliEventType.UserChoice, ChoiceQuestion = question, ChoiceOptions = choices }); } catch (JsonException) { var fallback = $":::ASK_USER_CHOICE:::\n{choiceJsonBuffer}\n{remaining}\n"; fullOutput.Append(fallback); await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = fallback }); } inChoiceCapture = false; choiceJsonBuffer.Clear(); } else if (!inChoiceCapture) { fullOutput.Append(remaining); await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining }); } } // Sicherstellen, dass offene Blöcke geschlossen werden if (inTextBlock) await onEvent(new CliStreamEvent { Type = CliEventType.TextEnd }); await process.WaitForExitAsync(cancellationToken); result.ExitCode = process.ExitCode; result.Output = fullOutput.ToString().Trim(); result.Success = process.ExitCode == 0 && (fullOutput.Length > 0 || userChoiceDetected); result.UserChoiceDetected = userChoiceDetected; if (!result.Success && string.IsNullOrEmpty(result.Error)) result.Error = error.Length > 0 ? error.ToString().Trim() : $"CLI exit code: {process.ExitCode}"; } catch (OperationCanceledException) { result.Success = false; result.Error = "Abgebrochen"; throw; } catch (Exception ex) { result.Success = false; result.Error = ex.Message; _logger.LogError(ex, "Fehler beim Streaming der Claude CLI"); } return result; } /// /// Prüft ob die CLI funktioniert und authentifiziert ist. /// public async Task GetStatusAsync() { var result = new CliStatusResult { IsAvailable = _isAvailable }; if (!_isAvailable) { result.Error = "Claude CLI nicht gefunden"; return result; } try { var versionResult = await RunSimpleAsync(["--version"], CancellationToken.None); if (versionResult.Success) result.Version = versionResult.Output.Trim(); var doctorResult = await RunSimpleAsync(["doctor"], CancellationToken.None); result.IsAuthenticated = doctorResult.Success; if (!doctorResult.Success) result.Error = doctorResult.Error ?? doctorResult.Output; } catch (Exception ex) { result.Error = ex.Message; } return result; } private async Task RunSimpleAsync(List args, CancellationToken cancellationToken) { var result = new CliChatResult(); var output = new StringBuilder(); var error = new StringBuilder(); try { var startInfo = new ProcessStartInfo { FileName = _cliPath, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8 }; foreach (var arg in args) startInfo.ArgumentList.Add(arg); using var process = new Process { StartInfo = startInfo }; process.OutputDataReceived += (s, e) => { if (e.Data != null) output.AppendLine(e.Data); }; process.ErrorDataReceived += (s, e) => { if (e.Data != null) error.AppendLine(e.Data); }; process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); await process.WaitForExitAsync(cancellationToken); result.ExitCode = process.ExitCode; result.Output = output.ToString().Trim(); result.Success = process.ExitCode == 0; if (!result.Success) result.Error = error.Length > 0 ? error.ToString().Trim() : $"Exit code: {process.ExitCode}"; } catch (OperationCanceledException) { result.Success = false; result.Error = "Abgebrochen"; throw; } catch (Exception ex) { result.Success = false; result.Error = ex.Message; _logger.LogError(ex, "Fehler beim Ausführen der Claude CLI"); } return result; } } public enum CliEventType { TextStart, TextDelta, TextEnd, ToolStart, ToolEnd, UserChoice } public class CliStreamEvent { public CliEventType Type { get; set; } public string Text { get; set; } public string ToolName { get; set; } public string ToolId { get; set; } public string ToolInput { get; set; } public string ChoiceQuestion { get; set; } public List ChoiceOptions { get; set; } } public class CliChatResult { public bool Success { get; set; } public string Output { get; set; } public string Error { get; set; } public int ExitCode { get; set; } public long InputTokens { get; set; } public long OutputTokens { get; set; } public long CacheReadTokens { get; set; } public long CacheCreationTokens { get; set; } public double CostUsd { get; set; } public long DurationApiMs { get; set; } public string SessionId { get; set; } public int NumTurns { get; set; } public bool UserChoiceDetected { get; set; } } public class CliStatusResult { public bool IsAvailable { get; set; } public bool IsAuthenticated { get; set; } public string Version { get; set; } public string Error { get; set; } }