Files
gott/Services/ClaudeCliService.cs
T
MelekiandClaude Opus 4.6 3b068a7866 feat: MCP-Server für remote_execute + Inline-Choice-UI
- Python MCP-Server (stdio, JSON-RPC 2.0) für remote_execute via Claude CLI
- Interner API-Endpoint /api/internal/remote-execute mit Token-Auth
- MCP-Config wird automatisch generiert wenn Remote-Verbindung vorhanden
- Auth-Middleware: /api/internal/ von Auth ausgenommen
- Frontend: :::ASK_USER_CHOICE::: Blöcke als interaktive Buttons rendern
- Streaming: Unvollständige Choice-Blöcke werden während des Streams versteckt
- tool-ui.js: MCP-Tool-Namen generisch normalisieren (mcp__server__tool → tool)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 09:00:50 +01:00

740 lines
33 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 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;
}
/// <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,
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" });
}
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"); }
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);
}
if (!string.IsNullOrEmpty(mcpConfigPath))
{
args.Add("--mcp-config");
args.Add(mcpConfigPath);
}
return await RunCliStreamingAsync(args, prompt, onEvent, cancellationToken);
}
private async Task<CliChatResult> RunCliStreamingAsync(
List<string> args,
string stdinPrompt,
Func<CliStreamEvent, Task> 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<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) && !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<JsonElement>(choiceJsonBuffer.ToString());
var question = choiceJson.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : "";
var choices = new List<string>();
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")
{
// 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 && !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<JsonElement>(choiceJsonBuffer.ToString());
var question = choiceJson.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : "";
var choices = new List<string>();
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";
}
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 && !userChoiceDetected)
{
var remaining = lineBuffer.ToString().Trim();
lineBuffer.Clear();
if (inChoiceCapture && remaining == ":::END_ASK_USER_CHOICE:::")
{
try
{
var choiceJson = JsonSerializer.Deserialize<JsonElement>(choiceJsonBuffer.ToString());
var question = choiceJson.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : "";
var choices = new List<string>();
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 (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 || 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;
}
/// <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 = 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,
ToolResult,
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 ToolResultContent { get; set; }
public bool ToolResultIsError { get; set; }
public string ChoiceQuestion { get; set; }
public List<string> 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; }
}