Files
gott/Services/ClaudeCliService.cs
T
MelekiandClaude Opus 4.7 e75c4370fd fix(encoding): UTF-8 prozess-global + 16-KB-StreamReader für CLI-Output
Zwei zusätzliche Härtungen gegen intermittierende �-Replacement-Chars
(Umlaute, Em-Dash) im CLI-Output unter Windows:

- Program.cs: Auf Windows Console.OutputEncoding/InputEncoding = UTF-8
  setzen. Wirkt prozess-global via Win32 SetConsoleOutputCP/SetConsoleCP
  und wird von direkt gestarteten Child-Prozessen geerbt – ergänzt den
  bestehenden chcp-65001-cmd-Wrapper. In try/catch, falls als Windows-
  Dienst ohne Console gestartet.
- ClaudeCliService.cs: stdout-Lesen mit eigenem StreamReader (16-KB-
  Buffer, explizit UTF-8 ohne BOM-Detection) statt Default-Reader (1 KB).
  Verhindert, dass bei gefragmentierten Pipe-Reads Multi-Byte-UTF-8-
  Sequenzen über Buffer-Grenzen aufgesplittet werden und der Decoder
  den State verliert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:12:24 +02:00

765 lines
31 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
// Eigener StreamReader mit großem Buffer + explizit UTF-8 ohne BOM-Detection.
// Default-Reader (process.StandardOutput) nutzt nur 1 KB Buffer, was bei
// gefragmentierten Pipe-Reads zu zerschnittenen Multi-Byte-UTF-8-Sequenzen
// und damit zu �-Replacement-Chars für Umlaute/Em-Dashes führen kann.
using var reader = new StreamReader(
process.StandardOutput.BaseStream,
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
detectEncodingFromByteOrderMarks: false,
bufferSize: 16384,
leaveOpen: false);
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; }
}