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 ProcessStartInfo CreateCliStartInfo(List args, bool redirectInput = false) { var psi = new ProcessStartInfo { FileName = _cliPath, RedirectStandardInput = redirectInput, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8 }; foreach (var arg in args) psi.ArgumentList.Add(arg); return psi; } private static string FindCliPath() { // 1. Expliziter Pfad über Umgebungsvariable (höchste Priorität) var envPath = Environment.GetEnvironmentVariable("CLAUDE_CLI_PATH"); if (!string.IsNullOrEmpty(envPath) && File.Exists(envPath)) return envPath; // 2. Bekannte Installationspfade var possiblePaths = new[] { // Windows: standalone install Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "claude-code", "claude.exe"), // Windows/Linux: local bin (funktioniert nur wenn Dienst unter dem richtigen User läuft) Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "claude.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "claude"), // Linux: system paths "/usr/local/bin/claude", "/usr/bin/claude" }; foreach (var path in possiblePaths) { if (File.Exists(path)) return path; } // 3. PATH-Suche try { var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? ""; foreach (var dir in pathEnv.Split(Path.PathSeparator)) { var exePath = Path.Combine(dir, "claude.exe"); if (File.Exists(exePath)) return exePath; exePath = Path.Combine(dir, "claude"); 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, string mcpConfigPath = null, bool hasUnityMcp = 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" }); } else { // Kein Workspace: Read für Temp-Dateien (z.B. gesendete Bilder) tools.Add("Read"); } if (webSearch) { tools.Add("WebFetch"); tools.Add("WebSearch"); } // MCP-Tools erlauben (wenn MCP-Config vorhanden) if (!string.IsNullOrEmpty(mcpConfigPath)) { tools.Add("mcp__gott-remote__remote_execute"); tools.Add("mcp__gott-remote__ask_user_choice"); tools.Add("mcp__gott-remote__memory_read"); tools.Add("mcp__gott-remote__memory_write"); tools.Add("mcp__gott-remote__memory_list"); } // Unity MCP Tools erlauben (alle Tools vom unity MCP-Server) if (hasUnityMcp) { tools.Add("mcp__unity__*"); } args.Add("--allowed-tools"); args.Add(tools.Count > 0 ? string.Join(" ", tools) : "\"\""); if (!string.IsNullOrEmpty(workspace)) { args.Add("--add-dir"); args.Add(workspace); } // Temp-Ordner immer zugänglich machen (für gesendete Bilder etc.) var tempImageDir = Path.Combine(Path.GetTempPath(), "gott-cli-images"); if (Directory.Exists(tempImageDir)) { args.Add("--add-dir"); args.Add(tempImageDir); } if (!string.IsNullOrEmpty(modelId)) { args.Add("--model"); args.Add(modelId); } if (!string.IsNullOrEmpty(systemPrompt)) { args.Add("--system-prompt"); args.Add(systemPrompt); } if (!string.IsNullOrEmpty(mcpConfigPath)) { args.Add("--mcp-config"); args.Add(mcpConfigPath); } 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; var lineBuffer = new StringBuilder(); try { var startInfo = CreateCliStartInfo(args, redirectInput: true); 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(); // Bei Cancellation den gesamten Prozessbaum beenden // (ReadLineAsync wirft OperationCanceledException, aber process.Dispose() killt NICHT den Prozess) using var killOnCancel = cancellationToken.Register(() => { try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch { } }); 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 (!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 + lineBuffer flushen if (inTextBlock) { // Restlichen Text im lineBuffer emittieren bevor Tool startet if (lineBuffer.Length > 0) { var remaining = lineBuffer.ToString(); lineBuffer.Clear(); if (remaining.Trim().Length > 0) { fullOutput.Append(remaining); await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining }); } } 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)) { for (int ci = 0; ci < text.Length; ci++) { var ch = text[ci]; if (ch == '\n') { var completedLine = lineBuffer.ToString().Trim(); lineBuffer.Clear(); if (completedLine.Length > 0 || fullOutput.Length > 0) { 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") { // lineBuffer flushen (Text ohne trailing \n) if (lineBuffer.Length > 0) { var remaining = lineBuffer.ToString(); lineBuffer.Clear(); if (remaining.Trim().Length > 0) { fullOutput.Append(remaining); await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining }); } } currentBlockType = null; } break; } } } else if (type == "result") { // Restlichen lineBuffer flushen (nicht-abgeschlossene Zeile) if (lineBuffer.Length > 0) { var remaining = lineBuffer.ToString().Trim(); lineBuffer.Clear(); if (remaining.Length > 0) { 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"; } else if (type == "user" && json.TryGetProperty("message", out var userMsg)) { // CLI sendet tool_result als type:"user" message if (userMsg.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Array) { foreach (var item in content.EnumerateArray()) { var itemType = item.TryGetProperty("type", out var iType) ? iType.GetString() : null; if (itemType == "tool_result") { var toolUseId = item.TryGetProperty("tool_use_id", out var tuId) ? tuId.GetString() : ""; var resultIsError = item.TryGetProperty("is_error", out var isErr) && isErr.GetBoolean(); // Content kann string oder array sein var resultContent = ""; if (item.TryGetProperty("content", out var rc)) { if (rc.ValueKind == JsonValueKind.String) resultContent = rc.GetString(); else if (rc.ValueKind == JsonValueKind.Array) { var sb = new StringBuilder(); foreach (var part in rc.EnumerateArray()) { if (part.TryGetProperty("text", out var partText)) sb.Append(partText.GetString()); } resultContent = sb.ToString(); } } await onEvent(new CliStreamEvent { Type = CliEventType.ToolResult, ToolId = toolUseId, ToolResultContent = resultContent, ToolResultIsError = resultIsError }); } } } } } 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) { var remaining = lineBuffer.ToString().Trim(); lineBuffer.Clear(); if (remaining.Length > 0) { fullOutput.Append(remaining); await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining }); } } // Sicherstellen, dass offene Blöcke geschlossen werden if (currentBlockType == "tool_use" && currentToolName != null) { await onEvent(new CliStreamEvent { Type = CliEventType.ToolEnd, ToolName = currentToolName, ToolId = currentToolId, ToolInput = currentToolInput.ToString() }); currentBlockType = null; } 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; 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 = CreateCliStartInfo(args); 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(); using var killOnCancel = cancellationToken.Register(() => { try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch { } }); 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, ToolResult } 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 ToolResultContent { get; set; } public bool ToolResultIsError { 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 class CliStatusResult { public bool IsAvailable { get; set; } public bool IsAuthenticated { get; set; } public string Version { get; set; } public string Error { get; set; } }