- AnthropicService.Streaming.cs + OpenAiService.cs: SSE-StreamReader nutzt jetzt expliziten UTF-8-Encoder mit 16-KB-Buffer (statt default 1 KB) und ohne BOM-Detection. Verhindert intermittierende Replacement-Chars (�), die entstehen können, wenn Multi-Byte-UTF-8-Sequenzen ungünstig über die Read-Boundary eines NetworkStreams gesplittet werden. - ClaudeCliService.cs: cmd.exe-Wrapper jetzt mit /D (AutoRun-Skripte aus Registry deaktivieren) und WorkingDirectory wird auf den Session-Workspace gesetzt. Vorher erbte der Child-Prozess das CWD von gott (bei Windows- Diensten C:\WINDOWS\system32). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
756 lines
30 KiB
C#
756 lines
30 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// 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).
|
||
/// </summary>
|
||
public class ClaudeCliService
|
||
{
|
||
private readonly ILogger<ClaudeCliService> _logger;
|
||
private readonly string _cliPath;
|
||
private readonly bool _isAvailable;
|
||
|
||
public ClaudeCliService(ILogger<ClaudeCliService> 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<string> args, bool redirectInput = false, string workingDirectory = null)
|
||
{
|
||
var psi = new ProcessStartInfo
|
||
{
|
||
RedirectStandardInput = redirectInput,
|
||
RedirectStandardOutput = true,
|
||
RedirectStandardError = true,
|
||
UseShellExecute = false,
|
||
CreateNoWindow = true,
|
||
StandardOutputEncoding = Encoding.UTF8,
|
||
StandardErrorEncoding = Encoding.UTF8
|
||
};
|
||
|
||
// CWD explizit auf den Workspace setzen, wenn einer angegeben wurde.
|
||
// Sonst erbt der Child-Prozess das CWD von gott (bei Windows-Diensten meist C:\WINDOWS\system32),
|
||
// und Claude meldet "your current working directory is C:\WINDOWS\system32".
|
||
if (!string.IsNullOrEmpty(workingDirectory) && Directory.Exists(workingDirectory))
|
||
psi.WorkingDirectory = workingDirectory;
|
||
|
||
if (OperatingSystem.IsWindows())
|
||
{
|
||
// Auf Windows: über cmd.exe wrappen und Codepage 65001 (UTF-8) erzwingen.
|
||
// Sonst kann der Child-Prozess (claude.exe) Output mit CP1252/CP850 schreiben,
|
||
// was beim UTF-8-Decode zu Replacement-Chars (�) für Umlaute / Em-Dash führt.
|
||
// /D = AutoRun-Skripte aus der Registry deaktivieren (sonst kann der User-AutoRun
|
||
// das CWD vor dem claude.exe-Start ändern)
|
||
// /S = Spezialregel für äußere Quotes – strippt führende/abschließende Quote
|
||
// /C = Kommando ausführen und beenden
|
||
var sb = new StringBuilder();
|
||
sb.Append("/D /S /C \"chcp 65001 >nul && ");
|
||
sb.Append(QuoteArgForCmd(_cliPath));
|
||
foreach (var a in args)
|
||
{
|
||
sb.Append(' ');
|
||
sb.Append(QuoteArgForCmd(a));
|
||
}
|
||
sb.Append('"');
|
||
|
||
psi.FileName = Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe";
|
||
psi.Arguments = sb.ToString();
|
||
}
|
||
else
|
||
{
|
||
psi.FileName = _cliPath;
|
||
foreach (var arg in args)
|
||
psi.ArgumentList.Add(arg);
|
||
}
|
||
|
||
// UTF-8-Locale für den Child setzen (Node.js / diverse Tools respektieren das).
|
||
// Schadet auf Windows nicht, hilft auf manchen Linux-Setups (z.B. systemd-Diensten ohne LANG).
|
||
psi.Environment["LANG"] = "en_US.UTF-8";
|
||
psi.Environment["LC_ALL"] = "en_US.UTF-8";
|
||
psi.Environment["PYTHONIOENCODING"] = "utf-8";
|
||
|
||
return psi;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Quotet ein Argument im Stil von Microsoft CRT (CommandLineToArgvW),
|
||
/// passend für die Verwendung innerhalb von cmd.exe /C "...".
|
||
/// </summary>
|
||
private static string QuoteArgForCmd(string arg)
|
||
{
|
||
if (string.IsNullOrEmpty(arg))
|
||
return "\"\"";
|
||
|
||
bool needsQuoting = false;
|
||
foreach (var c in arg)
|
||
{
|
||
if (c == ' ' || c == '\t' || c == '"' || c == '\n' || c == '\v')
|
||
{
|
||
needsQuoting = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!needsQuoting)
|
||
return arg;
|
||
|
||
var sb = new StringBuilder();
|
||
sb.Append('"');
|
||
int backslashes = 0;
|
||
foreach (var c in arg)
|
||
{
|
||
if (c == '\\')
|
||
{
|
||
backslashes++;
|
||
}
|
||
else if (c == '"')
|
||
{
|
||
// 2n+1 Backslashes vor einem Anführungszeichen
|
||
sb.Append('\\', backslashes * 2 + 1);
|
||
sb.Append('"');
|
||
backslashes = 0;
|
||
}
|
||
else
|
||
{
|
||
sb.Append('\\', backslashes);
|
||
sb.Append(c);
|
||
backslashes = 0;
|
||
}
|
||
}
|
||
// 2n Backslashes am Ende (vor dem schließenden ")
|
||
sb.Append('\\', backslashes * 2);
|
||
sb.Append('"');
|
||
return sb.ToString();
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Streaming-Chat über die Claude CLI mit Event-basiertem Callback.
|
||
/// Unterstützt Text-Streaming und Tool-Use Events.
|
||
/// </summary>
|
||
public async Task<CliChatResult> ChatStreamingAsync(
|
||
string prompt,
|
||
Func<CliStreamEvent, Task> 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<string>
|
||
{
|
||
"-p",
|
||
"--verbose",
|
||
"--output-format", "stream-json",
|
||
"--include-partial-messages",
|
||
"--no-session-persistence",
|
||
"--permission-mode", "acceptEdits",
|
||
"--disallowed-tools", "AskUserQuestion"
|
||
};
|
||
|
||
// Tools konfigurieren
|
||
var tools = new List<string>();
|
||
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, workspace, cancellationToken);
|
||
}
|
||
|
||
private async Task<CliChatResult> RunCliStreamingAsync(
|
||
List<string> args,
|
||
string stdinPrompt,
|
||
Func<CliStreamEvent, Task> onEvent,
|
||
string workingDirectory,
|
||
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, workingDirectory: workingDirectory);
|
||
|
||
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<JsonElement>(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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Prüft ob die CLI funktioniert und authentifiziert ist.
|
||
/// </summary>
|
||
public async Task<CliStatusResult> 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<CliChatResult> RunSimpleAsync(List<string> 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; }
|
||
}
|