using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
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;
///
/// Aktiviert detailliertes Encoding-Debugging für CLI-stdout.
/// Schreibt alle rohen Bytes vom CLI-Prozess in eine .bin-Datei und
/// loggt jedes Vorkommen von U+FFFD (Replacement-Char) in eine .log-Datei.
/// Aktivierung: Umgebungsvariable GOTT_CLI_DEBUG_BYTES=1
///
private static bool IsByteDebugEnabled()
{
var v = Environment.GetEnvironmentVariable("GOTT_CLI_DEBUG_BYTES");
return !string.IsNullOrEmpty(v) && (v == "1" || string.Equals(v, "true", StringComparison.OrdinalIgnoreCase));
}
private static string GetDebugDir()
{
// data/cli-debug/ relativ zur Anwendung – wird automatisch erstellt
var dir = Path.Combine(AppContext.BaseDirectory, "data", "cli-debug");
Directory.CreateDirectory(dir);
return dir;
}
private ProcessStartInfo CreateCliStartInfo(List args, bool redirectInput = false, string effort = null, string workingDirectory = null)
{
var psi = new ProcessStartInfo
{
FileName = _cliPath,
RedirectStandardInput = redirectInput,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
// Arbeitsverzeichnis explizit auf den Workspace setzen.
// Ohne das erbt der CLI-Child das CWD des gott-Servers (unter Windows das
// Dienst-/Installationsverzeichnis), wodurch RELATIVE Tool-Pfade wie
// Glob "Sources/**" ins Leere laufen ("No files found"), während absolute
// Pfade funktionieren. --add-dir erteilt nur Zugriff, setzt aber NICHT das CWD.
if (!string.IsNullOrEmpty(workingDirectory) && Directory.Exists(workingDirectory))
psi.WorkingDirectory = workingDirectory;
// 1h-Prompt-Cache-TTL für Claude CLI aktivieren (seit CLI 2.1.108).
// Anthropic hat Anfang März 2026 den Default von 1h auf 5min gesenkt,
// dieser Flag holt die 1h-TTL zurück (gilt für API-Key, Bedrock, Vertex, Foundry).
psi.Environment["ENABLE_PROMPT_CACHING_1H"] = "1";
// Effort-Level via Env-Var setzen (höchste Priorität, deckt auch "max" und "ultracode" ab).
// Das "--effort"-Flag unterstützt nur low/medium/high/xhigh – Env-Var deckt alles.
if (!string.IsNullOrEmpty(effort) && effort != "high")
psi.Environment["CLAUDE_CODE_EFFORT_LEVEL"] = effort;
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,
string effort = null,
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, effort, workspace);
}
private async Task RunCliStreamingAsync(
List args,
string stdinPrompt,
Func onEvent,
CancellationToken cancellationToken,
string effort = null,
string workspace = null)
{
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();
// Encoding-Debug Cleanup-Holder (im finally geschlossen)
StreamWriter debugMarkerWriter = null;
try
{
var startInfo = CreateCliStartInfo(args, redirectInput: true, effort: effort, workingDirectory: workspace);
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();
// === ENCODING-DIAGNOSE === (nur aktiv wenn GOTT_CLI_DEBUG_BYTES=1)
// Wenn aktiviert, alle rohen Bytes in eine .bin-Datei teen und Marker-Log
// für jedes Vorkommen von U+FFFD schreiben. So können wir feststellen, ob das
// Replacement-Char schon vom Claude-CLI kommt (.bin enthält bereits invalide
// UTF-8-Bytes) oder erst beim Decode entsteht (.bin ist sauber, aber decoded
// line hat �). Im Default-Modus (env nicht gesetzt) bleibt der ursprüngliche
// Default-Reader aktiv – kein zusätzlicher Disk-IO im Normalbetrieb.
bool debugBytes = IsByteDebugEnabled();
FileStream rawStream = null;
StreamWriter markerWriter = null;
string debugSessionId = null;
if (debugBytes)
{
try
{
debugSessionId = DateTime.Now.ToString("yyyyMMdd-HHmmss-fff");
var debugDir = GetDebugDir();
var rawPath = Path.Combine(debugDir, $"cli-stdout-{debugSessionId}.bin");
var markerPath = Path.Combine(debugDir, $"cli-stdout-{debugSessionId}.log");
rawStream = new FileStream(rawPath, FileMode.Create, FileAccess.Write, FileShare.Read);
markerWriter = new StreamWriter(markerPath, append: false, new UTF8Encoding(false)) { AutoFlush = true };
debugMarkerWriter = markerWriter; // für finally-Cleanup
markerWriter.WriteLine($"# CLI Encoding Debug Log – Session {debugSessionId}");
markerWriter.WriteLine($"# Roh-Bytes: {Path.GetFileName(rawPath)}");
markerWriter.WriteLine($"# Args: {string.Join(" ", args)}");
markerWriter.WriteLine($"# OS: {RuntimeInformation.OSDescription}, ACP={Encoding.Default.CodePage}, OEM={Console.OutputEncoding.CodePage}");
markerWriter.WriteLine();
_logger.LogWarning("CLI encoding debug aktiv. Roh-Bytes → {Raw}, Marker-Log → {Log}", rawPath, markerPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Konnte CLI-Debug-Logging nicht initialisieren – fahre ohne fort");
debugBytes = false;
rawStream?.Dispose(); rawStream = null;
markerWriter?.Dispose(); markerWriter = null;
}
}
// Im Debug-Modus: Custom-Reader mit TeeStream (16 KB Buffer + UTF-8 ohne BOM-Detection).
// Im Default-Modus: ursprünglicher Reader (= process.StandardOutput) – kein Encoding-
// override, weil die diversen Härtungsversuche (chcp, UTF-8-Reader, Console.OutputEncoding)
// das �-Problem nachweislich nicht behoben haben. Wir warten auf das Diagnose-Ergebnis,
// bevor wir den nächsten Fix-Versuch starten.
using StreamReader reader = (debugBytes && rawStream != null)
? new StreamReader(
new TeeReadStream(process.StandardOutput.BaseStream, rawStream, leaveTeeOpen: false),
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
detectEncodingFromByteOrderMarks: false,
bufferSize: 16384,
leaveOpen: false)
: process.StandardOutput;
string line;
int lineNum = 0;
while ((line = await reader.ReadLineAsync(cancellationToken)) != null)
{
lineNum++;
// Marker-Log: U+FFFD im decoded Output erkennen
if (debugBytes && markerWriter != null && line.IndexOf('�') >= 0)
{
try
{
var firstAt = line.IndexOf('�');
int ctxStart = Math.Max(0, firstAt - 40);
int ctxEnd = Math.Min(line.Length, firstAt + 40);
var ctx = line.Substring(ctxStart, ctxEnd - ctxStart);
// U+FFFD im Kontext durch sichtbares Marker ersetzen für die Log-Zeile
var ctxVis = ctx.Replace("�", "<>");
markerWriter.WriteLine($"[Line {lineNum}] FFFD count={CountChar(line, '�')} firstPos={firstAt} totalLen={line.Length}");
markerWriter.WriteLine($" Context: ...{ctxVis}...");
markerWriter.WriteLine($" Full line (first 500 chars): {(line.Length > 500 ? line.Substring(0, 500) : line).Replace("�", "<>")}");
markerWriter.WriteLine();
_logger.LogWarning("CLI U+FFFD detected on line {Ln} at pos {Pos}: ...{Ctx}...", lineNum, firstAt, ctxVis);
}
catch { }
}
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");
}
finally
{
// Encoding-Debug Marker-Writer abschließen (rawStream wird vom TeeReadStream-Dispose geschlossen)
if (debugMarkerWriter != null)
{
try
{
debugMarkerWriter.WriteLine();
debugMarkerWriter.WriteLine($"# End. fullOutput length = {fullOutput.Length}, FFFD count = {CountChar(fullOutput.ToString(), '�')}");
debugMarkerWriter.Flush();
debugMarkerWriter.Dispose();
}
catch { }
}
}
return result;
}
private static int CountChar(string s, char c)
{
if (string.IsNullOrEmpty(s)) return 0;
int n = 0;
for (int i = 0; i < s.Length; i++) if (s[i] == c) n++;
return n;
}
///
/// 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;
}
}
///
/// Stream-Wrapper, der jedes gelesene Byte parallel in einen zweiten Stream (Tee-File) schreibt.
/// Wird für Encoding-Diagnose verwendet, um die rohen Bytes vom Claude-CLI-Prozess zu konservieren,
/// bevor sie durch den UTF-8-StreamReader gehen.
///
internal sealed class TeeReadStream : Stream
{
private readonly Stream _source;
private readonly Stream _tee;
private readonly bool _leaveTeeOpen;
private long _totalRead;
public TeeReadStream(Stream source, Stream tee, bool leaveTeeOpen = false)
{
_source = source ?? throw new ArgumentNullException(nameof(source));
_tee = tee ?? throw new ArgumentNullException(nameof(tee));
_leaveTeeOpen = leaveTeeOpen;
}
public long TotalRead => _totalRead;
public override bool CanRead => _source.CanRead;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position { get => _totalRead; set => throw new NotSupportedException(); }
public override int Read(byte[] buffer, int offset, int count)
{
var n = _source.Read(buffer, offset, count);
if (n > 0)
{
_tee.Write(buffer, offset, n);
_tee.Flush();
_totalRead += n;
}
return n;
}
public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default)
{
var n = await _source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (n > 0)
{
await _tee.WriteAsync(buffer.Slice(0, n), cancellationToken).ConfigureAwait(false);
await _tee.FlushAsync(cancellationToken).ConfigureAwait(false);
_totalRead += n;
}
return n;
}
public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
var n = await _source.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false);
if (n > 0)
{
await _tee.WriteAsync(buffer.AsMemory(offset, n), cancellationToken).ConfigureAwait(false);
await _tee.FlushAsync(cancellationToken).ConfigureAwait(false);
_totalRead += n;
}
return n;
}
public override void Flush() { /* read-only */ }
public override int Read(Span buffer)
{
var n = _source.Read(buffer);
if (n > 0)
{
_tee.Write(buffer.Slice(0, n));
_tee.Flush();
_totalRead += n;
}
return n;
}
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (disposing)
{
try { _source.Dispose(); } catch { }
try
{
if (!_leaveTeeOpen) _tee.Dispose();
else _tee.Flush();
}
catch { }
}
base.Dispose(disposing);
}
}
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; }
}