Die Claude-CLI-Permission-Engine parst eine Allowlist-Regel via split("__")
zu {serverName, toolName} und vergleicht den Servernamen per strenger
Gleichheit (===); ein '*' wirkt ausschliesslich im Tool-Teil, nie im
Server-Teil. Die generalisierte Form "mcp__*<name>*" parste damit zu einem
Server namens "*<name>*" und matchte NIE -> jeder ueber die MCP-Liste
eingetragene Server wurde headless still abgelehnt (Regression aus 5f7dec6).
- ClaudeCliService.cs: Allowlist-Glob mcp__*<name>* -> mcp__<name>__*
(exakter Server + Tool-Wildcard, dieselbe Form wie mcp__web-search-prime__*);
Kommentar erklaert die strenge Server-Gleichheit.
- index.html: UI-Hinweis korrigiert - Name muss exakt dem Tool-Praefix
entsprechen (z.B. UnityMCP, nicht Unity); Freigabe als mcp__<name>__*.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1189 lines
54 KiB
C#
1189 lines
54 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// Benutzerdefinierter MCP-Server (pro Session). URL-basiert (http/sse), optionaler Bearer-Token.
|
||
/// </summary>
|
||
public class McpServerDef
|
||
{
|
||
public string Name { get; set; }
|
||
public string Url { get; set; }
|
||
public string Token { get; set; }
|
||
}
|
||
|
||
/// <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;
|
||
private readonly ServerSettingsService _settings;
|
||
private readonly WorkflowProgressTracker _workflowTracker;
|
||
|
||
public ClaudeCliService(ILogger<ClaudeCliService> logger, ServerSettingsService settings, WorkflowProgressTracker workflowTracker = null)
|
||
{
|
||
_logger = logger;
|
||
_settings = settings;
|
||
_workflowTracker = workflowTracker;
|
||
_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;
|
||
|
||
/// <summary>
|
||
/// 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
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Erkennt z.ai/GLM-Modelle. Diese laufen über den Anthropic-kompatiblen z.ai-Endpoint,
|
||
/// nicht über das normale Claude-OAuth-Login.
|
||
/// </summary>
|
||
public static bool IsZaiModel(string modelId)
|
||
=> !string.IsNullOrEmpty(modelId) && modelId.StartsWith("glm-", System.StringComparison.OrdinalIgnoreCase);
|
||
|
||
/// <summary>
|
||
/// GLM-5.2 (und höher) hat (laut z.ai-Doku) ein Reasoning-Effort-Mapping:
|
||
/// Claude Code mappt low/medium/high → GLM-"high", xhigh/max → GLM-"max". Ältere GLM-Modelle
|
||
/// (4.x, 5, 5-turbo, 5.1) kennen kein Effort. Bewusst ein Versionsvergleich statt Prefix-Match,
|
||
/// damit künftige Releases (5.3, 6, …) automatisch mitgenommen werden. Erfasst auch
|
||
/// Varianten-Suffixe wie "glm-5.2[1m]" oder "glm-5-turbo".
|
||
/// </summary>
|
||
private static bool IsZaiEffortModel(string modelId)
|
||
{
|
||
if (string.IsNullOrEmpty(modelId) || !modelId.StartsWith("glm-", System.StringComparison.OrdinalIgnoreCase))
|
||
return false;
|
||
// Führende Versionsziffern nach "glm-" extrahieren: "5.2[1m]" → 5.2, "5-turbo" → 5(.0).
|
||
var rest = modelId.Substring(4);
|
||
int i = 0;
|
||
while (i < rest.Length && (char.IsDigit(rest[i]) || rest[i] == '.')) i++;
|
||
var versionStr = rest.Substring(0, i).Trim('.');
|
||
if (versionStr.Length == 0) return false;
|
||
var parts = versionStr.Split('.');
|
||
if (!int.TryParse(parts[0], out var major)) return false;
|
||
var minor = 0;
|
||
if (parts.Length > 1) int.TryParse(parts[1], out minor);
|
||
// GLM-5.2 oder höher.
|
||
return major > 5 || (major == 5 && minor >= 2);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Erkennt den tool_result des dynamischen Workflow-Tools ("Workflow launched in background …
|
||
/// Transcript dir: …") und registriert den Lauf im WorkflowProgressTracker. Der Workflow läuft
|
||
/// als detachter Hintergrund-Task mit eigenem Transcript – seine Live-/End-Telemetrie wird
|
||
/// später aus den dort liegenden Dateien (journal.jsonl / <runId>.json) gelesen, nicht aus
|
||
/// dem Stream. Rein additiv – wirft nie (Telemetrie darf den Stream-Parser nicht stören).
|
||
/// </summary>
|
||
private void TapWorkflowLaunch(string sessionId, string chainId, string resultContent)
|
||
{
|
||
try
|
||
{
|
||
if (_workflowTracker == null || string.IsNullOrEmpty(sessionId) || string.IsNullOrEmpty(resultContent))
|
||
return;
|
||
if (resultContent.IndexOf("Workflow launched", StringComparison.OrdinalIgnoreCase) < 0)
|
||
return;
|
||
|
||
// Zeile "Transcript dir: <absoluter Pfad>" extrahieren.
|
||
string transcriptDir = null;
|
||
foreach (var raw in resultContent.Split('\n'))
|
||
{
|
||
var line = raw.Trim();
|
||
const string marker = "Transcript dir:";
|
||
if (line.StartsWith(marker, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
transcriptDir = line.Substring(marker.Length).Trim();
|
||
break;
|
||
}
|
||
}
|
||
if (!string.IsNullOrEmpty(transcriptDir))
|
||
_workflowTracker.Register(sessionId, chainId, transcriptDir);
|
||
}
|
||
catch { /* Telemetrie darf den Stream nie stören */ }
|
||
}
|
||
|
||
private ProcessStartInfo CreateCliStartInfo(List<string> args, bool redirectInput = false, string effort = null, string workingDirectory = null, string modelId = 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;
|
||
|
||
// z.ai / GLM: das Anthropic-kompatible z.ai-Backend NUR für diesen Prozess aktivieren.
|
||
// Da gott pro Request einen eigenen CLI-Prozess startet, gilt das ausschließlich für
|
||
// diesen Lauf – das Claude-OAuth-Login aller anderen Requests bleibt unberührt.
|
||
if (IsZaiModel(modelId))
|
||
{
|
||
var zaiKey = _settings?.Get("ZaiApiKey");
|
||
if (!string.IsNullOrEmpty(zaiKey))
|
||
{
|
||
psi.Environment["ANTHROPIC_BASE_URL"] = "https://api.z.ai/api/anthropic";
|
||
psi.Environment["ANTHROPIC_AUTH_TOKEN"] = zaiKey;
|
||
// Interne CLI-Hilfsaufrufe (z.B. Haiku-Klasse für Titel/Utility) auf GLM mappen,
|
||
// damit sie nicht gegen unbekannte Claude-Modellnamen bei z.ai laufen.
|
||
psi.Environment["ANTHROPIC_DEFAULT_SONNET_MODEL"] = modelId;
|
||
psi.Environment["ANTHROPIC_DEFAULT_OPUS_MODEL"] = modelId;
|
||
psi.Environment["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = "glm-4.5-air";
|
||
// Effort/Reasoning: NUR GLM-5.2 hat ein definiertes Effort-Mapping bei z.ai
|
||
// (Claude Code mappt es selbst: low/medium/high → "high", xhigh/max → "max").
|
||
// Für 5.2 reichen wir CLAUDE_CODE_EFFORT_LEVEL daher durch; alle anderen GLM-Modelle
|
||
// kennen kein Effort → Env-Var entfernen, damit nichts Undefiniertes gesendet wird.
|
||
if (!IsZaiEffortModel(modelId))
|
||
psi.Environment.Remove("CLAUDE_CODE_EFFORT_LEVEL");
|
||
// Ein evtl. global gesetzter Anthropic-API-Key darf den z.ai-Token nicht überschreiben.
|
||
psi.Environment.Remove("ANTHROPIC_API_KEY");
|
||
}
|
||
else
|
||
{
|
||
_logger.LogWarning("GLM-Modell '{Model}' gewählt, aber ZaiApiKey nicht konfiguriert – Request läuft NICHT über z.ai.", modelId);
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reservierte MCP-Server-Namen (gott-intern) – dürfen von benutzerdefinierten Servern
|
||
/// nicht überschrieben werden, sonst würde die interne Config/Allowlist kollidieren.
|
||
/// </summary>
|
||
private static readonly HashSet<string> _reservedMcpNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
"gott-remote", "web-search-prime"
|
||
};
|
||
|
||
/// <summary>
|
||
/// Parst die in der Session hinterlegte MCP-Server-Liste (JSON-Array von {name,url,token}).
|
||
/// Säubert die Namen (glob-/config-tauglich), filtert reservierte Namen, Duplikate und
|
||
/// Einträge ohne URL. Wirft nie – ungültige Eingaben ergeben eine leere Liste.
|
||
/// </summary>
|
||
public static List<McpServerDef> ParseMcpServers(string json)
|
||
{
|
||
var result = new List<McpServerDef>();
|
||
if (string.IsNullOrWhiteSpace(json)) return result;
|
||
try
|
||
{
|
||
using var doc = JsonDocument.Parse(json);
|
||
if (doc.RootElement.ValueKind != JsonValueKind.Array) return result;
|
||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
foreach (var el in doc.RootElement.EnumerateArray())
|
||
{
|
||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||
var rawName = el.TryGetProperty("name", out var n) && n.ValueKind == JsonValueKind.String ? n.GetString() : null;
|
||
var url = el.TryGetProperty("url", out var u) && u.ValueKind == JsonValueKind.String ? u.GetString() : null;
|
||
var token = el.TryGetProperty("token", out var t) && t.ValueKind == JsonValueKind.String ? t.GetString() : null;
|
||
var name = SanitizeMcpName(rawName);
|
||
if (string.IsNullOrEmpty(name) || string.IsNullOrWhiteSpace(url)) continue;
|
||
if (_reservedMcpNames.Contains(name)) continue;
|
||
if (!seen.Add(name)) continue; // doppelter Name → ignorieren
|
||
result.Add(new McpServerDef { Name = name, Url = url.Trim(), Token = string.IsNullOrWhiteSpace(token) ? null : token.Trim() });
|
||
}
|
||
}
|
||
catch { /* ungültiges JSON → leere Liste */ }
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reduziert einen MCP-Server-Namen auf glob-/config-taugliche Zeichen (Buchstaben, Ziffern,
|
||
/// '_' und '-'). Andere Zeichen (inkl. Leerzeichen) werden entfernt.
|
||
/// </summary>
|
||
private static string SanitizeMcpName(string raw)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||
var sb = new StringBuilder();
|
||
foreach (var c in raw.Trim())
|
||
{
|
||
if (char.IsLetterOrDigit(c) || c == '_' || c == '-') sb.Append(c);
|
||
}
|
||
return sb.Length > 0 ? sb.ToString() : 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 ultracode = false,
|
||
bool webSearch = false,
|
||
string mcpConfigPath = null,
|
||
List<string> mcpServerNames = null,
|
||
string effort = null,
|
||
string sessionId = null,
|
||
string chainId = null,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
if (!_isAvailable)
|
||
return new CliChatResult { Success = false, Error = "Claude CLI nicht verfügbar" };
|
||
|
||
// Permission-Mode: Im Plan-Modus (auch Panel-Member laufen read-only via planMode=true)
|
||
// MUSS die Schreib-Sperre über die Permission-Ebene erzwungen werden. 'acceptEdits' würde
|
||
// Write/Edit AUTOMATISCH akzeptieren – UNABHÄNGIG von --allowed-tools – sodass ein als
|
||
// read-only gedachter Member (oder ein regulärer Plan-Modus-User) trotzdem Dateien schreiben
|
||
// könnte. 'plan' blockiert alle Schreib-/Edit-/Bash-Operationen auf Permission-Ebene.
|
||
var permissionMode = planMode ? "plan" : "acceptEdits";
|
||
|
||
// Ultracode nutzt xhigh-Reasoning (das reale Äquivalent – "ultracode" selbst ist KEIN
|
||
// gültiger Effort-Wert der CLI, wird still ignoriert). Ein bereits gewähltes "max" wird
|
||
// nicht heruntergestuft.
|
||
var effortToUse = ultracode ? (effort == "max" ? "max" : "xhigh") : effort;
|
||
|
||
var args = new List<string>
|
||
{
|
||
"-p",
|
||
"--verbose",
|
||
"--output-format", "stream-json",
|
||
"--include-partial-messages",
|
||
"--no-session-persistence",
|
||
"--permission-mode", permissionMode
|
||
};
|
||
|
||
// Tools, die das Modell gar nicht erst anbieten/versuchen soll.
|
||
var disallowedTools = new List<string> { "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");
|
||
}
|
||
// Ultracode: die nativen dynamischen Workflows der Claude CLI zulassen. Ohne diese
|
||
// Freigabe lehnt die Permission-Engine das Workflow-Tool headless STILL ab (kein
|
||
// interaktiver Gate im -p-Modus) → der Workflow läuft nicht. Task = die Subagenten,
|
||
// die ein Workflow spawnt. Nur Claude (z.ai/GLM hat kein Workflow-Runtime), nicht im
|
||
// Plan-Modus (read-only).
|
||
if (ultracode && !planMode && !IsZaiModel(modelId))
|
||
{
|
||
tools.Add("Workflow");
|
||
tools.Add("Task");
|
||
}
|
||
if (webSearch)
|
||
{
|
||
// GLM/z.ai kann Anthropics server-seitige WebSearch/WebFetch nicht ausführen →
|
||
// stattdessen z.ais Web-Search-Prime-MCP-Tool zulassen. Der zugehörige MCP-Server
|
||
// wird in GenerateMcpConfig injiziert, sobald GLM + Web-Suche + ZaiApiKey gesetzt sind.
|
||
if (IsZaiModel(modelId) && !string.IsNullOrEmpty(_settings?.Get("ZaiApiKey")))
|
||
{
|
||
tools.Add("mcp__web-search-prime__*");
|
||
// Die nativen Web-Tools komplett verbieten: sonst versucht das Modell zuerst
|
||
// eine (über z.ai nicht ausführbare) WebSearch, scheitert headless an der
|
||
// Berechtigung und greift erst im zweiten Anlauf zum Prime-MCP. Mit dem Verbot
|
||
// werden sie dem Modell gar nicht angeboten → direkt das Prime-Tool.
|
||
disallowedTools.Add("WebSearch");
|
||
disallowedTools.Add("WebFetch");
|
||
}
|
||
else
|
||
{
|
||
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");
|
||
}
|
||
// Benutzerdefinierte MCP-Server: alle Tools je Server erlauben.
|
||
// WICHTIG: Die CLI-Permission-Engine vergleicht den SERVER-Namen per strenger
|
||
// Gleichheit (Regel wird als mcp__<server>__<tool> geparst; ein '*' wirkt NUR im
|
||
// Tool-Teil, NICHT im Server-Teil). Ein Glob wie "mcp__*<name>*" würde zu einem
|
||
// Server namens "*<name>*" parsen und NIE matchen. Daher exakte Form
|
||
// "mcp__<name>__*" (exakter Server + alle Tools) – dieselbe Form wie web-search-prime.
|
||
// Konsequenz: Der eingetragene Name MUSS dem echten Tool-Präfix entsprechen
|
||
// (z.B. "UnityMCP", nicht "Unity").
|
||
if (mcpServerNames != null)
|
||
{
|
||
foreach (var mcpName in mcpServerNames)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(mcpName))
|
||
tools.Add($"mcp__{mcpName}__*");
|
||
}
|
||
}
|
||
args.Add("--allowed-tools");
|
||
args.Add(tools.Count > 0 ? string.Join(" ", tools) : "\"\"");
|
||
|
||
args.Add("--disallowed-tools");
|
||
args.Add(string.Join(" ", disallowedTools));
|
||
|
||
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);
|
||
}
|
||
|
||
// Defensiv: falls eine unaufgelöste z.ai-ID ("glm:glm-4.6") durchrutscht, Prefix strippen,
|
||
// damit --model die rohe ID (z.B. "glm-4.6") erhält, die z.ai erwartet.
|
||
if (!string.IsNullOrEmpty(modelId) && modelId.StartsWith("glm:", System.StringComparison.OrdinalIgnoreCase))
|
||
modelId = modelId.Substring(4);
|
||
|
||
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, effortToUse, workspace, modelId, sessionId, chainId);
|
||
}
|
||
|
||
private async Task<CliChatResult> RunCliStreamingAsync(
|
||
List<string> args,
|
||
string stdinPrompt,
|
||
Func<CliStreamEvent, Task> onEvent,
|
||
CancellationToken cancellationToken,
|
||
string effort = null,
|
||
string workspace = null,
|
||
string modelId = null,
|
||
string sessionId = null,
|
||
string chainId = 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, modelId: modelId);
|
||
|
||
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("�", "<<U+FFFD>>");
|
||
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("�", "<<U+FFFD>>")}");
|
||
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<JsonElement>(line);
|
||
var type = json.TryGetProperty("type", out var t) ? t.GetString() : null;
|
||
|
||
// Subagent-Events ausblenden: Top-Level-Messages mit gesetztem
|
||
// parent_tool_use_id stammen aus einem per 'Agent'-Tool gespawnten
|
||
// Subagenten. Deren Interna (tool_use kommt als type:"assistant" ohne
|
||
// Handler → verworfen; tool_result als type:"user" → erzeugte sonst eine
|
||
// namenlose, label-lose Box) werden konsequent unterdrückt – konsistent
|
||
// zum Session-Agent. Die Tool-Box des Agent-Aufrufs selbst hat
|
||
// parent_tool_use_id=null und bleibt sichtbar; der finale 'result'-Event
|
||
// (Tokens, inkl. aggregierter Subagent-Tokens) trägt ebenfalls keins.
|
||
if (json.TryGetProperty("parent_tool_use_id", out var parentToolUseId)
|
||
&& parentToolUseId.ValueKind == JsonValueKind.String
|
||
&& !string.IsNullOrEmpty(parentToolUseId.GetString()))
|
||
{
|
||
// Subagent-Event aus dem Haupt-Stream AUSBLENDEN (continue). Hinweis: Die
|
||
// dynamischen Workflow-Subagenten laufen out-of-band (eigener Hintergrund-
|
||
// Task + Transcript) und tauchen hier ohnehin NICHT auf – ihre Telemetrie
|
||
// wird aus den Transcript-Dateien gelesen (siehe TapWorkflowLaunch). Dieser
|
||
// Guard betrifft nur klassische Task-Subagenten (sichtbarer Verlauf bleibt sauber).
|
||
continue;
|
||
}
|
||
|
||
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 == "assistant" && json.TryGetProperty("message", out var asMsg)
|
||
&& asMsg.TryGetProperty("usage", out var asUsage))
|
||
{
|
||
// Konsolidierter assistant-Turn des HAUPT-Streams (Subagenten sind durch den
|
||
// parent_tool_use_id-Guard oben bereits ausgefiltert). message.usage liefert die
|
||
// Live-Token dieses Turns – die einzige Token-Quelle VOR dem finalen result-Event.
|
||
// Wir emittieren sie, damit der Aufrufer bei einem Abbruch (kein result-Event)
|
||
// nicht 0 Token persistiert. Der Stream selbst bleibt unberührt.
|
||
long AInt(string n) => asUsage.TryGetProperty(n, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetInt64() : 0L;
|
||
await onEvent(new CliStreamEvent
|
||
{
|
||
Type = CliEventType.Usage,
|
||
InputTokens = AInt("input_tokens"),
|
||
OutputTokens = AInt("output_tokens"),
|
||
CacheReadTokens = AInt("cache_read_input_tokens"),
|
||
CacheCreationTokens = AInt("cache_creation_input_tokens")
|
||
});
|
||
}
|
||
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
|
||
});
|
||
|
||
// Dynamischen Workflow-Start abgreifen (Transcript-Pfad merken),
|
||
// damit der /workflow-status-Endpoint die Telemetrie aus den
|
||
// Transcript-Dateien lesen kann. Stört den Stream nie.
|
||
if (!resultIsError)
|
||
TapWorkflowLaunch(sessionId, chainId, resultContent);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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;
|
||
}
|
||
|
||
/// <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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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<int> ReadAsync(Memory<byte> 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<int> 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<byte> 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,
|
||
Usage // Orchestrator-Usage eines assistant-Turns (Live-Akkumulation, Token-Fix bei Abbruch)
|
||
}
|
||
|
||
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; }
|
||
// Usage-Event: per-Turn-Usage des Haupt-Streams (kein Subagent – parent_tool_use_id ist null)
|
||
public long InputTokens { get; set; }
|
||
public long OutputTokens { get; set; }
|
||
public long CacheReadTokens { get; set; }
|
||
public long CacheCreationTokens { 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; }
|
||
}
|